From 5678862038fff5b0a9a68d7a8a20163faf09568c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 24 Jun 2024 22:21:13 -0400 Subject: [PATCH 001/115] Deduplicate common subscription logic --- lib/philomena/channels.ex | 70 +---------- lib/philomena/comments.ex | 20 +-- lib/philomena/forums.ex | 47 +------ lib/philomena/galleries.ex | 55 +------- lib/philomena/images.ex | 70 +---------- lib/philomena/posts.ex | 16 +-- lib/philomena/subscriptions.ex | 224 +++++++++++++++++++++++++++++++++ lib/philomena/topics.ex | 73 +---------- 8 files changed, 254 insertions(+), 321 deletions(-) create mode 100644 lib/philomena/subscriptions.ex diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index d1d31bce..ea07f0d5 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -8,7 +8,10 @@ defmodule Philomena.Channels do alias Philomena.Channels.AutomaticUpdater alias Philomena.Channels.Channel - alias Philomena.Notifications + + use Philomena.Subscriptions, + actor_types: ~w(Channel LivestreamChannel), + id_name: :channel_id @doc """ Updates all the tracked channels for which an update scheme is known. @@ -115,69 +118,4 @@ defmodule Philomena.Channels do def change_channel(%Channel{} = channel) do Channel.changeset(channel, %{}) end - - alias Philomena.Channels.Subscription - - @doc """ - Creates a subscription. - - ## Examples - - iex> create_subscription(%{field: value}) - {:ok, %Subscription{}} - - iex> create_subscription(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_subscription(_channel, nil), do: {:ok, nil} - - def create_subscription(channel, user) do - %Subscription{channel_id: channel.id, user_id: user.id} - |> Subscription.changeset(%{}) - |> Repo.insert(on_conflict: :nothing) - end - - @doc """ - Deletes a Subscription. - - ## Examples - - iex> delete_subscription(subscription) - {:ok, %Subscription{}} - - iex> delete_subscription(subscription) - {:error, %Ecto.Changeset{}} - - """ - def delete_subscription(channel, user) do - clear_notification(channel, user) - - %Subscription{channel_id: channel.id, user_id: user.id} - |> Repo.delete() - end - - def subscribed?(_channel, nil), do: false - - def subscribed?(channel, user) do - Subscription - |> where(channel_id: ^channel.id, user_id: ^user.id) - |> Repo.exists?() - end - - def subscriptions(_channels, nil), do: %{} - - def subscriptions(channels, user) do - channel_ids = Enum.map(channels, & &1.id) - - Subscription - |> where([s], s.channel_id in ^channel_ids and s.user_id == ^user.id) - |> Repo.all() - |> Map.new(&{&1.channel_id, true}) - end - - def clear_notification(channel, user) do - Notifications.delete_unread_notification("Channel", channel.id, user) - Notifications.delete_unread_notification("LivestreamChannel", channel.id, user) - end end diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 1d606ff0..359347d0 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -19,7 +19,6 @@ defmodule Philomena.Comments do alias Philomena.NotificationWorker alias Philomena.Versions alias Philomena.Reports - alias Philomena.Users.User @doc """ Gets a single comment. @@ -58,24 +57,17 @@ defmodule Philomena.Comments do Image |> where(id: ^image.id) + image_lock_query = + lock(image_query, "FOR UPDATE") + Multi.new() + |> Multi.one(:image, image_lock_query) |> Multi.insert(:comment, comment) - |> Multi.update_all(:image, image_query, inc: [comments_count: 1]) - |> maybe_create_subscription_on_reply(image, attribution[:user]) + |> Multi.update_all(:update_image, image_query, inc: [comments_count: 1]) + |> Images.maybe_subscribe_on(:image, attribution[:user], :watch_on_reply) |> Repo.transaction() end - defp maybe_create_subscription_on_reply(multi, image, %User{watch_on_reply: true} = user) do - multi - |> Multi.run(:subscribe, fn _repo, _changes -> - Images.create_subscription(image, user) - end) - end - - defp maybe_create_subscription_on_reply(multi, _image, _user) do - multi - end - def notify_comment(comment) do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Comments", comment.id]) end diff --git a/lib/philomena/forums.ex b/lib/philomena/forums.ex index f77df915..ba8006bc 100644 --- a/lib/philomena/forums.ex +++ b/lib/philomena/forums.ex @@ -7,8 +7,10 @@ defmodule Philomena.Forums do alias Philomena.Repo alias Philomena.Forums.Forum - alias Philomena.Forums.Subscription - alias Philomena.Notifications + + use Philomena.Subscriptions, + actor_types: ~w(Forum), + id_name: :forum_id @doc """ Returns the list of forums. @@ -103,45 +105,4 @@ defmodule Philomena.Forums do def change_forum(%Forum{} = forum) do Forum.changeset(forum, %{}) end - - def subscribed?(_forum, nil), do: false - - def subscribed?(forum, user) do - Subscription - |> where(forum_id: ^forum.id, user_id: ^user.id) - |> Repo.exists?() - end - - def create_subscription(_forum, nil), do: {:ok, nil} - - def create_subscription(forum, user) do - %Subscription{forum_id: forum.id, user_id: user.id} - |> Subscription.changeset(%{}) - |> Repo.insert(on_conflict: :nothing) - end - - @doc """ - Deletes a Subscription. - - ## Examples - - iex> delete_subscription(subscription) - {:ok, %Subscription{}} - - iex> delete_subscription(subscription) - {:error, %Ecto.Changeset{}} - - """ - def delete_subscription(forum, user) do - clear_notification(forum, user) - - %Subscription{forum_id: forum.id, user_id: user.id} - |> Repo.delete() - end - - def clear_notification(_forum, nil), do: nil - - def clear_notification(forum, user) do - Notifications.delete_unread_notification("Forum", forum.id, user) - end end diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index 1943fc25..b60b8b72 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -18,6 +18,10 @@ defmodule Philomena.Galleries do alias Philomena.Notifications.{Notification, UnreadNotification} alias Philomena.Images + use Philomena.Subscriptions, + actor_types: ~w(Gallery), + id_name: :gallery_id + @doc """ Gets a single gallery. @@ -356,55 +360,4 @@ defmodule Philomena.Galleries do defp position_order(%{order_position_asc: true}), do: [asc: :position] defp position_order(_gallery), do: [desc: :position] - - alias Philomena.Galleries.Subscription - - def subscribed?(_gallery, nil), do: false - - def subscribed?(gallery, user) do - Subscription - |> where(gallery_id: ^gallery.id, user_id: ^user.id) - |> Repo.exists?() - end - - @doc """ - Creates a subscription. - - ## Examples - - iex> create_subscription(%{field: value}) - {:ok, %Subscription{}} - - iex> create_subscription(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_subscription(gallery, user) do - %Subscription{gallery_id: gallery.id, user_id: user.id} - |> Subscription.changeset(%{}) - |> Repo.insert(on_conflict: :nothing) - end - - @doc """ - Deletes a Subscription. - - ## Examples - - iex> delete_subscription(subscription) - {:ok, %Subscription{}} - - iex> delete_subscription(subscription) - {:error, %Ecto.Changeset{}} - - """ - def delete_subscription(gallery, user) do - %Subscription{gallery_id: gallery.id, user_id: user.id} - |> Repo.delete() - end - - def clear_notification(_gallery, nil), do: nil - - def clear_notification(gallery, user) do - Notifications.delete_unread_notification("Gallery", gallery.id, user) - end end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index d68595fb..af775c4c 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -37,6 +37,10 @@ defmodule Philomena.Images do alias Philomena.Galleries.Interaction alias Philomena.Users.User + use Philomena.Subscriptions, + actor_types: ~w(Image), + id_name: :image_id + @doc """ Gets a single image. @@ -103,7 +107,7 @@ defmodule Philomena.Images do {:ok, count} end) - |> maybe_create_subscription_on_upload(attribution[:user]) + |> maybe_subscribe_on(:image, attribution[:user], :watch_on_upload) |> Repo.transaction() |> case do {:ok, %{image: image}} = result -> @@ -157,17 +161,6 @@ defmodule Philomena.Images do Logger.error("Aborting upload of #{image.id} after #{retry_count} retries") end - defp maybe_create_subscription_on_upload(multi, %User{watch_on_upload: true} = user) do - multi - |> Multi.run(:subscribe, fn _repo, %{image: image} -> - create_subscription(image, user) - end) - end - - defp maybe_create_subscription_on_upload(multi, _user) do - multi - end - def approve_image(image) do image |> Repo.preload(:user) @@ -868,53 +861,6 @@ defmodule Philomena.Images do alias Philomena.Images.Subscription - def subscribed?(_image, nil), do: false - - def subscribed?(image, user) do - Subscription - |> where(image_id: ^image.id, user_id: ^user.id) - |> Repo.exists?() - end - - @doc """ - Creates a subscription. - - ## Examples - - iex> create_subscription(%{field: value}) - {:ok, %Subscription{}} - - iex> create_subscription(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_subscription(_image, nil), do: {:ok, nil} - - def create_subscription(image, user) do - %Subscription{image_id: image.id, user_id: user.id} - |> Subscription.changeset(%{}) - |> Repo.insert(on_conflict: :nothing) - end - - @doc """ - Deletes a subscription. - - ## Examples - - iex> delete_subscription(subscription) - {:ok, %Subscription{}} - - iex> delete_subscription(subscription) - {:error, %Ecto.Changeset{}} - - """ - def delete_subscription(image, user) do - clear_notification(image, user) - - %Subscription{image_id: image.id, user_id: user.id} - |> Repo.delete() - end - def migrate_subscriptions(source, target) do subscriptions = Subscription @@ -968,10 +914,4 @@ defmodule Philomena.Images do } ) end - - def clear_notification(_image, nil), do: nil - - def clear_notification(image, user) do - Notifications.delete_unread_notification("Image", image.id, user) - end end diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 16795e6c..b591ce0e 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -20,7 +20,6 @@ defmodule Philomena.Posts do alias Philomena.Versions alias Philomena.Reports alias Philomena.Reports.Report - alias Philomena.Users.User @doc """ Gets a single post. @@ -66,7 +65,7 @@ defmodule Philomena.Posts do |> where(id: ^topic.forum_id) Multi.new() - |> Multi.all(:topic_lock, topic_lock_query) + |> Multi.one(:topic, topic_lock_query) |> Multi.run(:post, fn repo, _ -> last_position = Post @@ -95,7 +94,7 @@ defmodule Philomena.Posts do {:ok, count} end) - |> maybe_create_subscription_on_reply(topic, attributes[:user]) + |> Topics.maybe_subscribe_on(:topic, attributes[:user], :watch_on_reply) |> Repo.transaction() |> case do {:ok, %{post: post}} = result -> @@ -108,17 +107,6 @@ defmodule Philomena.Posts do end end - defp maybe_create_subscription_on_reply(multi, topic, %User{watch_on_reply: true} = user) do - multi - |> Multi.run(:subscribe, fn _repo, _changes -> - Topics.create_subscription(topic, user) - end) - end - - defp maybe_create_subscription_on_reply(multi, _topic, _user) do - multi - end - def notify_post(post) do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Posts", post.id]) end diff --git a/lib/philomena/subscriptions.ex b/lib/philomena/subscriptions.ex new file mode 100644 index 00000000..8c0d53f0 --- /dev/null +++ b/lib/philomena/subscriptions.ex @@ -0,0 +1,224 @@ +defmodule Philomena.Subscriptions do + @moduledoc """ + Common subscription logic. + + `use Philomena.Subscriptions` requires the following properties: + + - `:actor_types` + This is the "actor_type" in the notifications table. + For `Philomena.Images`, this would be `["Image"]`. + + - `:id_name` + This is the name of the object field in the subscription table. + For `Philomena.Images`, this would be `:image_id`. + + The following functions and documentation are produced in the calling module: + - `subscribed?/2` + - `subscriptions/2` + - `create_subscription/2` + - `delete_subscription/2` + - `clear_notification/2` + - `maybe_subscribe_on/4` + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + + alias Philomena.Notifications + alias Philomena.Repo + + defmacro __using__(opts) do + # For Philomena.Images, this yields ["Image"] + actor_types = Keyword.fetch!(opts, :actor_types) + + # For Philomena.Images, this yields :image_id + field_name = Keyword.fetch!(opts, :id_name) + + # For Philomena.Images, this yields Philomena.Images.Subscription + subscription_module = Module.concat(__CALLER__.module, Subscription) + + quote do + @doc """ + Returns whether the user is currently subscribed to this object. + + ## Examples + + iex> subscribed?(object, user) + false + + """ + def subscribed?(object, user) do + Philomena.Subscriptions.subscribed?( + unquote(subscription_module), + unquote(field_name), + object, + user + ) + end + + @doc """ + Returns a map containing whether the user is currently subscribed to any of + the provided objects. + + ## Examples + + iex> subscriptions([%{id: 1}, %{id: 2}], user) + %{2 => true} + + """ + def subscriptions(objects, user) do + Philomena.Subscriptions.subscriptions( + unquote(subscription_module), + unquote(field_name), + objects, + user + ) + end + + @doc """ + Creates a subscription. + + ## Examples + + iex> create_subscription(object, user) + {:ok, %Subscription{}} + + iex> create_subscription(object, user) + {:error, %Ecto.Changeset{}} + + """ + def create_subscription(object, user) do + Philomena.Subscriptions.create_subscription( + unquote(subscription_module), + unquote(field_name), + object, + user + ) + end + + @doc """ + Deletes a subscription and removes notifications for it. + + ## Examples + + iex> delete_subscription(object, user) + {:ok, %Subscription{}} + + iex> delete_subscription(object, user) + {:error, %Ecto.Changeset{}} + + """ + def delete_subscription(object, user) do + clear_notification(object, user) + + Philomena.Subscriptions.delete_subscription( + unquote(subscription_module), + unquote(field_name), + object, + user + ) + end + + @doc """ + Deletes any active notifications for a subscription. + + ## Examples + + iex> clear_notification(object, user) + :ok + + """ + def clear_notification(object, user) do + for type <- unquote(actor_types) do + Philomena.Subscriptions.clear_notification(type, object, user) + end + + :ok + end + + @doc """ + Creates a subscription inside the `m:Ecto.Multi` flow if `user` is not nil + and `field` in `user` is `true`. + + Valid values for field are `:watch_on_reply`, `:watch_on_upload`, `:watch_on_new_topic`. + + ## Examples + + iex> maybe_subscribe_on(multi, :image, user, :watch_on_reply) + %Ecto.Multi{} + + iex> maybe_subscribe_on(multi, :topic, nil, :watch_on_reply) + %Ecto.Multi{} + + """ + def maybe_subscribe_on(multi, change_name, user, field) do + Philomena.Subscriptions.maybe_subscribe_on(multi, __MODULE__, change_name, user, field) + end + end + end + + @doc false + def subscribed?(subscription_module, field_name, object, user) do + case user do + nil -> + false + + _ -> + subscription_module + |> where([s], field(s, ^field_name) == ^object.id and s.user_id == ^user.id) + |> Repo.exists?() + end + end + + @doc false + def subscriptions(subscription_module, field_name, objects, user) do + case user do + nil -> + %{} + + _ -> + object_ids = Enum.map(objects, & &1.id) + + subscription_module + |> where([s], field(s, ^field_name) in ^object_ids and s.user_id == ^user.id) + |> Repo.all() + |> Map.new(&{Map.fetch!(&1, field_name), true}) + end + end + + @doc false + def create_subscription(subscription_module, field_name, object, user) do + struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}]) + |> subscription_module.changeset(%{}) + |> Repo.insert(on_conflict: :nothing) + end + + @doc false + def delete_subscription(subscription_module, field_name, object, user) do + struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}]) + |> Repo.delete() + end + + @doc false + def clear_notification(type, object, user) do + case user do + nil -> nil + _ -> Notifications.delete_unread_notification(type, object.id, user) + end + end + + @doc false + def maybe_subscribe_on(multi, module, change_name, user, field) + when field in [:watch_on_reply, :watch_on_upload, :watch_on_new_topic] do + case user do + %{^field => true} -> + Multi.run(multi, :subscribe, fn _repo, changes -> + object = Map.fetch!(changes, change_name) + module.create_subscription(object, user) + end) + + _ -> + multi + end + end +end diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 4892c06d..a2356f92 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -12,7 +12,10 @@ defmodule Philomena.Topics do alias Philomena.Posts alias Philomena.Notifications alias Philomena.NotificationWorker - alias Philomena.Users.User + + use Philomena.Subscriptions, + actor_types: ~w(Topic), + id_name: :topic_id @doc """ Gets a single topic. @@ -70,7 +73,7 @@ defmodule Philomena.Topics do {:ok, count} end) - |> maybe_create_subscription_on_new_topic(attribution[:user]) + |> maybe_subscribe_on(:topic, attribution[:user], :watch_on_new_topic) |> Repo.transaction() |> case do {:ok, %{topic: topic}} = result -> @@ -84,17 +87,6 @@ defmodule Philomena.Topics do end end - defp maybe_create_subscription_on_new_topic(multi, %User{watch_on_new_topic: true} = user) do - multi - |> Multi.run(:subscribe, fn _repo, %{topic: topic} -> - create_subscription(topic, user) - end) - end - - defp maybe_create_subscription_on_new_topic(multi, _user) do - multi - end - def notify_topic(topic, post) do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Topics", [topic.id, post.id]]) end @@ -173,55 +165,6 @@ defmodule Philomena.Topics do Topic.changeset(topic, %{}) end - alias Philomena.Topics.Subscription - - def subscribed?(_topic, nil), do: false - - def subscribed?(topic, user) do - Subscription - |> where(topic_id: ^topic.id, user_id: ^user.id) - |> Repo.exists?() - end - - @doc """ - Creates a subscription. - - ## Examples - - iex> create_subscription(%{field: value}) - {:ok, %Subscription{}} - - iex> create_subscription(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_subscription(_topic, nil), do: {:ok, nil} - - def create_subscription(topic, user) do - %Subscription{topic_id: topic.id, user_id: user.id} - |> Subscription.changeset(%{}) - |> Repo.insert(on_conflict: :nothing) - end - - @doc """ - Deletes a Subscription. - - ## Examples - - iex> delete_subscription(subscription) - {:ok, %Subscription{}} - - iex> delete_subscription(subscription) - {:error, %Ecto.Changeset{}} - - """ - def delete_subscription(topic, user) do - clear_notification(topic, user) - - %Subscription{topic_id: topic.id, user_id: user.id} - |> Repo.delete() - end - def stick_topic(topic) do Topic.stick_changeset(topic) |> Repo.update() @@ -299,10 +242,4 @@ defmodule Philomena.Topics do |> Topic.title_changeset(attrs) |> Repo.update() end - - def clear_notification(_topic, nil), do: nil - - def clear_notification(topic, user) do - Notifications.delete_unread_notification("Topic", topic.id, user) - end end From 1795e989157d9d55cc1739589374261c7b0aff1a Mon Sep 17 00:00:00 2001 From: "Luna D." Date: Wed, 3 Jul 2024 21:32:38 +0200 Subject: [PATCH 002/115] suppress background task output in development --- docker/app/run-development | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/app/run-development b/docker/app/run-development index 39900c76..dd7bcae4 100755 --- a/docker/app/run-development +++ b/docker/app/run-development @@ -15,11 +15,11 @@ export CARGO_HOME=/srv/philomena/.cargo background() { while :; do - mix run -e 'Philomena.Release.update_channels()' - mix run -e 'Philomena.Release.verify_artist_links()' - mix run -e 'Philomena.Release.update_stats()' - mix run -e 'Philomena.Release.clean_moderation_logs()' - mix run -e 'Philomena.Release.generate_autocomplete()' + mix run -e 'Philomena.Release.update_channels()' > /dev/null + mix run -e 'Philomena.Release.verify_artist_links()' > /dev/null + mix run -e 'Philomena.Release.update_stats()' > /dev/null + mix run -e 'Philomena.Release.clean_moderation_logs()' > /dev/null + mix run -e 'Philomena.Release.generate_autocomplete()' > /dev/null sleep 300 done From f002825c99e26ac1fa615206531a42ec7417ae8f Mon Sep 17 00:00:00 2001 From: "Luna D." Date: Wed, 3 Jul 2024 20:23:01 -0400 Subject: [PATCH 003/115] Add prettier to dependencies --- assets/.prettierrc.yml | 14 +++ assets/eslint.config.js | 2 + assets/package-lock.json | 216 +++++++++++++++++++++++++++++---------- assets/package.json | 5 +- 4 files changed, 184 insertions(+), 53 deletions(-) create mode 100644 assets/.prettierrc.yml diff --git a/assets/.prettierrc.yml b/assets/.prettierrc.yml new file mode 100644 index 00000000..83cfc971 --- /dev/null +++ b/assets/.prettierrc.yml @@ -0,0 +1,14 @@ +tabWidth: 2 +useTabs: false +printWidth: 120 +semi: true +singleQuote: true +bracketSpacing: true +endOfLine: lf +quoteProps: as-needed +trailingComma: all +arrowParens: avoid +overrides: + - files: "*.css" + options: + singleQuote: false diff --git a/assets/eslint.config.js b/assets/eslint.config.js index 166da758..70b27dc6 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -1,9 +1,11 @@ import tsEslint from 'typescript-eslint'; import vitestPlugin from 'eslint-plugin-vitest'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import globals from 'globals'; export default tsEslint.config( ...tsEslint.configs.recommended, + eslintPluginPrettierRecommended, { name: 'PhilomenaConfig', files: ['**/*.js', '**/*.ts'], diff --git a/assets/package-lock.json b/assets/package-lock.json index bb2b24cd..9f53e1b8 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -14,7 +14,7 @@ "normalize-scss": "^8.0.0", "sass": "^1.75.0", "typescript": "^5.4", - "typescript-eslint": "8.0.0-alpha.30", + "typescript-eslint": "8.0.0-alpha.39", "vite": "^5.2" }, "devDependencies": { @@ -23,8 +23,11 @@ "@types/chai-dom": "^1.11.3", "@vitest/coverage-v8": "^1.6.0", "chai": "^5", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-vitest": "^0.5.4", "jsdom": "^24.1.0", + "prettier": "^3.3.2", "vitest": "^1.6.0", "vitest-fetch-mock": "^0.2.2" } @@ -807,6 +810,18 @@ "node": ">= 8" } }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -1205,15 +1220,15 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0-alpha.30.tgz", - "integrity": "sha512-2CBUupdkfbE3eATph4QeZejvT+M+1bVur+zXlVx09WN31phap51ps/qemeclnCbGEz6kTgBDmScrr9XmmF8/Pg==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0-alpha.39.tgz", + "integrity": "sha512-ILv1vDA8M9ah1vzYpnOs4UOLRdB63Ki/rsxedVikjMLq68hFfpsDR25bdMZ4RyUkzLJwOhcg3Jujm/C1nupXKA==", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.0-alpha.30", - "@typescript-eslint/type-utils": "8.0.0-alpha.30", - "@typescript-eslint/utils": "8.0.0-alpha.30", - "@typescript-eslint/visitor-keys": "8.0.0-alpha.30", + "@typescript-eslint/scope-manager": "8.0.0-alpha.39", + "@typescript-eslint/type-utils": "8.0.0-alpha.39", + "@typescript-eslint/utils": "8.0.0-alpha.39", + "@typescript-eslint/visitor-keys": "8.0.0-alpha.39", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1237,14 +1252,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.0-alpha.30.tgz", - "integrity": "sha512-tAYgFmgXU1MlCK3nbblUvJlDSibBvxtAQXGrF3IG0KmnRza9FXILZifHWL0rrwacDn40K53K607Fk2QkMjiGgw==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.0-alpha.39.tgz", + "integrity": "sha512-5k+pwV91plJojHgZkWlq4/TQdOrnEaeSvt48V0m8iEwdMJqX/63BXYxy8BUOSghWcjp05s73vy9HJjovAKmHkQ==", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.0-alpha.30", - "@typescript-eslint/types": "8.0.0-alpha.30", - "@typescript-eslint/typescript-estree": "8.0.0-alpha.30", - "@typescript-eslint/visitor-keys": "8.0.0-alpha.30", + "@typescript-eslint/scope-manager": "8.0.0-alpha.39", + "@typescript-eslint/types": "8.0.0-alpha.39", + "@typescript-eslint/typescript-estree": "8.0.0-alpha.39", + "@typescript-eslint/visitor-keys": "8.0.0-alpha.39", "debug": "^4.3.4" }, "engines": { @@ -1264,12 +1279,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.0-alpha.30.tgz", - "integrity": "sha512-FGW/iPWGyPFamAVZ60oCAthMqQrqafUGebF8UKuq/ha+e9SVG6YhJoRzurlQXOVf8dHfOhJ0ADMXyFnMc53clg==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.0-alpha.39.tgz", + "integrity": "sha512-HCBlKQROY+JIgWolucdFMj1W3VUnnIQTdxAhxJTAj3ix2nASmvKIFgrdo5KQMrXxQj6tC4l3zva10L+s0dUIIw==", "dependencies": { - "@typescript-eslint/types": "8.0.0-alpha.30", - "@typescript-eslint/visitor-keys": "8.0.0-alpha.30" + "@typescript-eslint/types": "8.0.0-alpha.39", + "@typescript-eslint/visitor-keys": "8.0.0-alpha.39" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1280,12 +1295,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.0-alpha.30.tgz", - "integrity": "sha512-FrnhlCKEKZKRbpDviHkIU9tayIUGTOfa+SjvrRv6p/AJIUv6QT8oRboRjLH/cCuwUEbM0k5UtRWYug4albHUqQ==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.0-alpha.39.tgz", + "integrity": "sha512-alO13fRU6yVeJbwl9ESI3AYhq5dQdz3Dpd0I5B4uezs2lvgYp44dZsj5hWyPz/kL7JFEsjbn+4b/CZA0OQJzjA==", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.0-alpha.30", - "@typescript-eslint/utils": "8.0.0-alpha.30", + "@typescript-eslint/typescript-estree": "8.0.0-alpha.39", + "@typescript-eslint/utils": "8.0.0-alpha.39", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1303,9 +1318,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.0-alpha.30.tgz", - "integrity": "sha512-4WzLlw27SO9pK9UFj/Hu7WGo8WveT0SEiIpFVsV2WwtQmLps6kouwtVCB8GJPZKJyurhZhcqCoQVQFmpv441Vg==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.0-alpha.39.tgz", + "integrity": "sha512-yINN7j0/+S1VGSp0IgH52oQvUx49vkOug6xbrDA/9o+U55yCAQKSvYWvzYjNa+SZE3hXI0zwvYtMVsIAAMmKIQ==", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1315,12 +1330,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0-alpha.30.tgz", - "integrity": "sha512-WSXbc9ZcXI+7yC+6q95u77i8FXz6HOLsw3ST+vMUlFy1lFbXyFL/3e6HDKQCm2Clt0krnoCPiTGvIn+GkYPn4Q==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0-alpha.39.tgz", + "integrity": "sha512-S8gREuP8r8PCxGegeojeXntx0P50ul9YH7c7JYpbLIIsEPNr5f7UHlm+I1NUbL04CBin4kvZ60TG4eWr/KKN9A==", "dependencies": { - "@typescript-eslint/types": "8.0.0-alpha.30", - "@typescript-eslint/visitor-keys": "8.0.0-alpha.30", + "@typescript-eslint/types": "8.0.0-alpha.39", + "@typescript-eslint/visitor-keys": "8.0.0-alpha.39", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1350,9 +1365,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1364,14 +1379,14 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0-alpha.30.tgz", - "integrity": "sha512-rfhqfLqFyXhHNDwMnHiVGxl/Z2q/3guQ1jLlGQ0hi9Rb7inmwz42crM+NnLPR+2vEnwyw1P/g7fnQgQ3qvFx4g==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0-alpha.39.tgz", + "integrity": "sha512-Nr2PrlfNhrNQTlFHlD7XJdTGw/Vt8qY44irk6bfjn9LxGdSG5e4c1R2UN6kvGMhhx20DBPbM7q3Z3r+huzmL1w==", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.0-alpha.30", - "@typescript-eslint/types": "8.0.0-alpha.30", - "@typescript-eslint/typescript-estree": "8.0.0-alpha.30" + "@typescript-eslint/scope-manager": "8.0.0-alpha.39", + "@typescript-eslint/types": "8.0.0-alpha.39", + "@typescript-eslint/typescript-estree": "8.0.0-alpha.39" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1385,11 +1400,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0-alpha.30.tgz", - "integrity": "sha512-XZuNurZxBqmr6ZIRIwWFq7j5RZd6ZlkId/HZEWyfciK+CWoyOxSF9Pv2VXH9Rlu2ZG2PfbhLz2Veszl4Pfn7yA==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0-alpha.39.tgz", + "integrity": "sha512-DVJ0UdhucZy+/1GlIy7FX2+CFhCeNAi4VwaEAe7u2UDenQr9/kGqvzx00UlpWibmEVDw4KsPOI7Aqa1+2Vqfmw==", "dependencies": { - "@typescript-eslint/types": "8.0.0-alpha.30", + "@typescript-eslint/types": "8.0.0-alpha.39", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2393,6 +2408,48 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-vitest": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/eslint-plugin-vitest/-/eslint-plugin-vitest-0.5.4.tgz", @@ -2679,6 +2736,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4056,6 +4119,33 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", + "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -4455,6 +4545,22 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -4555,6 +4661,12 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4587,13 +4699,13 @@ } }, "node_modules/typescript-eslint": { - "version": "8.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.0.0-alpha.30.tgz", - "integrity": "sha512-/vGhBMsK1TpadQh1eQ02c5pyiPGmKR9cVzX5C9plZ+LC0HPLpWoJbbTVfQN7BkIK7tUxDt2BFr3pFL5hDDrx7g==", + "version": "8.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.0.0-alpha.39.tgz", + "integrity": "sha512-bsuR1BVJfHr7sBh7Cca962VPIcP+5UWaIa/+6PpnFZ+qtASjGTxKWIF5dG2o73BX9NsyqQfvRWujb3M9CIoRXA==", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.0.0-alpha.30", - "@typescript-eslint/parser": "8.0.0-alpha.30", - "@typescript-eslint/utils": "8.0.0-alpha.30" + "@typescript-eslint/eslint-plugin": "8.0.0-alpha.39", + "@typescript-eslint/parser": "8.0.0-alpha.39", + "@typescript-eslint/utils": "8.0.0-alpha.39" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/assets/package.json b/assets/package.json index 6a5d31c3..9da50439 100644 --- a/assets/package.json +++ b/assets/package.json @@ -12,7 +12,6 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", "@types/web": "^0.0.148", - "typescript-eslint": "8.0.0-alpha.30", "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", "eslint": "^9.4.0", @@ -20,6 +19,7 @@ "normalize-scss": "^8.0.0", "sass": "^1.75.0", "typescript": "^5.4", + "typescript-eslint": "8.0.0-alpha.39", "vite": "^5.2" }, "devDependencies": { @@ -28,8 +28,11 @@ "@types/chai-dom": "^1.11.3", "@vitest/coverage-v8": "^1.6.0", "chai": "^5", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-vitest": "^0.5.4", "jsdom": "^24.1.0", + "prettier": "^3.3.2", "vitest": "^1.6.0", "vitest-fetch-mock": "^0.2.2" } From 34d45b4197442c7961dc35e47416dd8706c924bd Mon Sep 17 00:00:00 2001 From: "Luna D." Date: Wed, 3 Jul 2024 20:27:59 -0400 Subject: [PATCH 004/115] Use prettier for JS formatting --- assets/eslint.config.js | 53 ++++--- assets/js/__tests__/imagesclientside.spec.ts | 25 ++-- assets/js/__tests__/input-duplicator.spec.ts | 27 ++-- assets/js/__tests__/ujs.spec.ts | 91 ++++++------ assets/js/__tests__/upload.spec.ts | 24 ++-- assets/js/autocomplete.js | 60 ++++---- assets/js/booru.js | 25 ++-- assets/js/boorujs.js | 58 ++++---- assets/js/burger.ts | 3 +- assets/js/captcha.ts | 6 +- assets/js/comment.js | 64 ++++----- assets/js/duplicate_reports.ts | 2 +- assets/js/fp.ts | 26 ++-- assets/js/galleries.ts | 18 ++- assets/js/image_expansion.js | 51 +++---- assets/js/imagesclientside.ts | 36 ++--- assets/js/input-duplicator.ts | 5 +- assets/js/interactions.js | 131 +++++++++-------- assets/js/markdowntoolbar.js | 132 +++++++++++------- assets/js/misc.ts | 10 +- assets/js/notifications.ts | 6 +- assets/js/pmwarning.ts | 3 +- assets/js/preview.js | 9 +- assets/js/query/__tests__/date.spec.ts | 4 +- assets/js/query/__tests__/user.spec.ts | 8 +- assets/js/query/date.ts | 34 +++-- assets/js/query/fields.ts | 23 +-- assets/js/query/lex.ts | 39 +++--- assets/js/query/literal.ts | 21 +-- assets/js/query/matcher.ts | 8 +- assets/js/query/parse.ts | 9 +- assets/js/query/term.ts | 6 +- assets/js/query/user.ts | 11 +- assets/js/quick-tag.js | 56 +++----- assets/js/resizablemedia.js | 21 +-- assets/js/search.js | 6 +- assets/js/settings.ts | 1 - assets/js/shortcuts.ts | 44 +++--- assets/js/tags.ts | 34 +++-- assets/js/tagsinput.js | 10 +- assets/js/tagsmisc.ts | 2 +- assets/js/timeago.ts | 34 ++--- assets/js/ujs.ts | 24 ++-- assets/js/upload.js | 79 ++++++----- assets/js/utils/__tests__/array.spec.ts | 65 +++------ assets/js/utils/__tests__/dom.spec.ts | 76 +++------- assets/js/utils/__tests__/draggable.spec.ts | 22 ++- assets/js/utils/__tests__/image.spec.ts | 88 ++++-------- .../__tests__/local-autocompleter.spec.ts | 6 +- assets/js/utils/__tests__/requests.spec.ts | 10 +- assets/js/utils/__tests__/store.spec.ts | 14 +- assets/js/utils/__tests__/tag.spec.ts | 36 ++--- assets/js/utils/dom.ts | 31 ++-- assets/js/utils/draggable.ts | 15 +- assets/js/utils/events.ts | 32 +++-- assets/js/utils/image.ts | 7 +- assets/js/utils/local-autocompleter.ts | 21 +-- assets/js/utils/requests.ts | 4 +- assets/js/utils/store.ts | 11 +- assets/js/utils/tag.ts | 6 +- assets/js/when-ready.ts | 60 ++++---- assets/test/fix-event-listeners.ts | 2 +- assets/test/mock-storage.ts | 12 +- assets/test/vitest-setup.ts | 4 +- assets/types/ujs.ts | 2 +- assets/vite.config.ts | 43 +++--- 66 files changed, 919 insertions(+), 987 deletions(-) diff --git a/assets/eslint.config.js b/assets/eslint.config.js index 70b27dc6..c927efb6 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -14,24 +14,22 @@ export default tsEslint.config( sourceType: 'module', parserOptions: { ecmaVersion: 6, - sourceType: 'module' + sourceType: 'module', }, globals: { - ...globals.browser - } + ...globals.browser, + }, }, rules: { 'accessor-pairs': 2, 'array-bracket-spacing': 0, 'array-callback-return': 2, 'arrow-body-style': 0, - 'arrow-parens': [2, 'as-needed'], 'arrow-spacing': 2, 'block-scoped-var': 2, 'block-spacing': 2, - 'brace-style': [2, 'stroustrup', {allowSingleLine: true}], 'callback-return': 0, - camelcase: [2, {allow: ['camo_url', 'spoiler_image_uri', 'image_ids']}], + camelcase: [2, { allow: ['camo_url', 'spoiler_image_uri', 'image_ids'] }], 'class-methods-use-this': 0, 'comma-dangle': [2, 'only-multiline'], 'comma-spacing': 2, @@ -44,7 +42,7 @@ export default tsEslint.config( curly: [2, 'multi-line', 'consistent'], 'default-case': 2, 'dot-location': [2, 'property'], - 'dot-notation': [2, {allowKeywords: true}], + 'dot-notation': [2, { allowKeywords: true }], 'eol-last': 2, eqeqeq: 2, 'func-call-spacing': 0, @@ -58,7 +56,6 @@ export default tsEslint.config( 'id-blacklist': 0, 'id-length': 0, 'id-match': 2, - indent: [2, 2, {SwitchCase: 1, VariableDeclarator: {var: 2, let: 2, const: 3}}], 'init-declarations': 0, 'jsx-quotes': 0, 'key-spacing': 0, @@ -112,7 +109,7 @@ export default tsEslint.config( 'no-extra-bind': 2, 'no-extra-boolean-cast': 2, 'no-extra-label': 2, - 'no-extra-parens': [2, 'all', {nestedBinaryExpressions: false}], + 'no-extra-parens': [2, 'all', { nestedBinaryExpressions: false }], 'no-extra-semi': 2, 'no-fallthrough': 2, 'no-floating-decimal': 2, @@ -138,7 +135,7 @@ export default tsEslint.config( 'no-mixed-spaces-and-tabs': 2, 'no-multi-spaces': 0, 'no-multi-str': 2, - 'no-multiple-empty-lines': [2, {max: 3, maxBOF: 0, maxEOF: 1}], + 'no-multiple-empty-lines': [2, { max: 3, maxBOF: 0, maxEOF: 1 }], 'no-native-reassign': 2, 'no-negated-condition': 0, 'no-negated-in-lhs': 2, @@ -192,9 +189,9 @@ export default tsEslint.config( 'no-unreachable': 2, 'no-unsafe-finally': 2, 'no-unsafe-negation': 2, - 'no-unused-expressions': [2, {allowShortCircuit: true, allowTernary: true}], + 'no-unused-expressions': [2, { allowShortCircuit: true, allowTernary: true }], 'no-unused-labels': 2, - 'no-unused-vars': [2, {vars: 'all', args: 'after-used', varsIgnorePattern: '^_', argsIgnorePattern: '^_'}], + 'no-unused-vars': [2, { vars: 'all', args: 'after-used', varsIgnorePattern: '^_', argsIgnorePattern: '^_' }], 'no-use-before-define': [2, 'nofunc'], 'no-useless-call': 2, 'no-useless-computed-key': 2, @@ -224,21 +221,19 @@ export default tsEslint.config( 'prefer-spread': 0, 'prefer-template': 2, 'quote-props': [2, 'as-needed'], - quotes: [2, 'single'], radix: 2, 'require-jsdoc': 0, 'require-yield': 2, 'rest-spread-spacing': 2, - 'semi-spacing': [2, {before: false, after: true}], + 'semi-spacing': [2, { before: false, after: true }], semi: 2, 'sort-imports': 0, 'sort-keys': 0, 'sort-vars': 0, 'space-before-blocks': [2, 'always'], - 'space-before-function-paren': [2, 'never'], 'space-in-parens': [2, 'never'], 'space-infix-ops': 2, - 'space-unary-ops': [2, {words: true, nonwords: false}], + 'space-unary-ops': [2, { words: true, nonwords: false }], 'spaced-comment': 0, strict: [2, 'function'], 'symbol-description': 2, @@ -253,18 +248,15 @@ export default tsEslint.config( 'yield-star-spacing': 2, yoda: [2, 'never'], }, - ignores: [ - 'js/vendor/*', - 'vite.config.ts' - ] + ignores: ['js/vendor/*', 'vite.config.ts'], }, { files: ['**/*.js'], rules: { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-expressions': 'off', - '@typescript-eslint/no-unused-vars': 'off' - } + '@typescript-eslint/no-unused-vars': 'off', + }, }, { files: ['**/*.ts'], @@ -273,15 +265,18 @@ export default tsEslint.config( 'no-unused-vars': 'off', 'no-redeclare': 'off', 'no-shadow': 'off', - '@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*'}], + '@typescript-eslint/no-unused-vars': [ + 2, + { vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*' }, + ], '@typescript-eslint/no-redeclare': 2, - '@typescript-eslint/no-shadow': 2 - } + '@typescript-eslint/no-shadow': 2, + }, }, { files: ['**/*.spec.ts', '**/test/*.ts'], plugins: { - vitest: vitestPlugin + vitest: vitestPlugin, }, rules: { ...vitestPlugin.configs.recommended.rules, @@ -289,7 +284,7 @@ export default tsEslint.config( 'no-undefined': 'off', 'no-unused-expressions': 0, 'vitest/valid-expect': 0, - '@typescript-eslint/no-unused-expressions': 0 - } - } + '@typescript-eslint/no-unused-expressions': 0, + }, + }, ); diff --git a/assets/js/__tests__/imagesclientside.spec.ts b/assets/js/__tests__/imagesclientside.spec.ts index 3f3feb88..f4c73ace 100644 --- a/assets/js/__tests__/imagesclientside.spec.ts +++ b/assets/js/__tests__/imagesclientside.spec.ts @@ -23,11 +23,11 @@ describe('filterNode', () => { `; - return [ element, assertNotNull($('.js-spoiler-info-overlay', element)) ]; + return [element, assertNotNull($('.js-spoiler-info-overlay', element))]; } it('should show image media boxes not matching any filter', () => { - const [ container, spoilerOverlay ] = makeMediaContainer(); + const [container, spoilerOverlay] = makeMediaContainer(); filterNode(container); expect(spoilerOverlay).not.toContainHTML('(Complex Filter)'); @@ -36,7 +36,7 @@ describe('filterNode', () => { }); it('should spoiler media boxes spoilered by a tag filter', () => { - const [ container, spoilerOverlay ] = makeMediaContainer(); + const [container, spoilerOverlay] = makeMediaContainer(); window.booru.spoileredTagList = [1]; filterNode(container); @@ -45,7 +45,7 @@ describe('filterNode', () => { }); it('should spoiler media boxes spoilered by a complex filter', () => { - const [ container, spoilerOverlay ] = makeMediaContainer(); + const [container, spoilerOverlay] = makeMediaContainer(); window.booru.spoileredFilter = parseSearch('id:1'); filterNode(container); @@ -54,7 +54,7 @@ describe('filterNode', () => { }); it('should hide media boxes hidden by a tag filter', () => { - const [ container, spoilerOverlay ] = makeMediaContainer(); + const [container, spoilerOverlay] = makeMediaContainer(); window.booru.hiddenTagList = [1]; filterNode(container); @@ -64,7 +64,7 @@ describe('filterNode', () => { }); it('should hide media boxes hidden by a complex filter', () => { - const [ container, spoilerOverlay ] = makeMediaContainer(); + const [container, spoilerOverlay] = makeMediaContainer(); window.booru.hiddenFilter = parseSearch('id:1'); filterNode(container); @@ -90,12 +90,12 @@ describe('filterNode', () => { element, assertNotNull($('.image-filtered', element)), assertNotNull($('.image-show', element)), - assertNotNull($('.filter-explanation', element)) + assertNotNull($('.filter-explanation', element)), ]; } it('should show image blocks not matching any filter', () => { - const [ container, imageFiltered, imageShow ] = makeImageBlock(); + const [container, imageFiltered, imageShow] = makeImageBlock(); filterNode(container); expect(imageFiltered).toHaveClass('hidden'); @@ -104,7 +104,7 @@ describe('filterNode', () => { }); it('should spoiler image blocks spoilered by a tag filter', () => { - const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock(); window.booru.spoileredTagList = [1]; filterNode(container); @@ -116,7 +116,7 @@ describe('filterNode', () => { }); it('should spoiler image blocks spoilered by a complex filter', () => { - const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock(); window.booru.spoileredFilter = parseSearch('id:1'); filterNode(container); @@ -128,7 +128,7 @@ describe('filterNode', () => { }); it('should hide image blocks hidden by a tag filter', () => { - const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock(); window.booru.hiddenTagList = [1]; filterNode(container); @@ -140,7 +140,7 @@ describe('filterNode', () => { }); it('should hide image blocks hidden by a complex filter', () => { - const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock(); window.booru.hiddenFilter = parseSearch('id:1'); filterNode(container); @@ -150,7 +150,6 @@ describe('filterNode', () => { expect(filterExplanation).toContainHTML('complex tag expression'); expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); }); - }); describe('initImagesClientside', () => { diff --git a/assets/js/__tests__/input-duplicator.spec.ts b/assets/js/__tests__/input-duplicator.spec.ts index 55233ee7..4e1e29b0 100644 --- a/assets/js/__tests__/input-duplicator.spec.ts +++ b/assets/js/__tests__/input-duplicator.spec.ts @@ -5,18 +5,21 @@ import { fireEvent } from '@testing-library/dom'; describe('Input duplicator functionality', () => { beforeEach(() => { - document.documentElement.insertAdjacentHTML('beforeend', `
-
3
-
- - -
-
- -
-
`); + document.documentElement.insertAdjacentHTML( + 'beforeend', + `
+
3
+
+ + +
+
+ +
+
`, + ); }); afterEach(() => { diff --git a/assets/js/__tests__/ujs.spec.ts b/assets/js/__tests__/ujs.spec.ts index b5b3d231..6833679e 100644 --- a/assets/js/__tests__/ujs.spec.ts +++ b/assets/js/__tests__/ujs.spec.ts @@ -29,7 +29,7 @@ describe('Remote utilities', () => { } describe('a[data-remote]', () => { - const submitA = ({ setMethod }: { setMethod: boolean; }) => { + const submitA = ({ setMethod }: { setMethod: boolean }) => { const a = document.createElement('a'); a.href = mockEndpoint; a.dataset.remote = 'remote'; @@ -51,8 +51,8 @@ describe('Remote utilities', () => { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'XMLHttpRequest' - } + 'x-requested-with': 'XMLHttpRequest', + }, }); }); @@ -64,21 +64,22 @@ describe('Remote utilities', () => { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'XMLHttpRequest' - } + 'x-requested-with': 'XMLHttpRequest', + }, }); }); - it('should emit fetchcomplete event', () => new Promise(resolve => { - let a: HTMLAnchorElement | null = null; + it('should emit fetchcomplete event', () => + new Promise(resolve => { + let a: HTMLAnchorElement | null = null; - addOneShotEventListener('fetchcomplete', event => { - expect(event.target).toBe(a); - resolve(); - }); + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(a); + resolve(); + }); - a = submitA({ setMethod: true }); - })); + a = submitA({ setMethod: true }); + })); }); describe('a[data-method]', () => { @@ -93,24 +94,25 @@ describe('Remote utilities', () => { return a; }; - it('should submit a form with the given action', () => new Promise(resolve => { - addOneShotEventListener('submit', event => { - event.preventDefault(); + it('should submit a form with the given action', () => + new Promise(resolve => { + addOneShotEventListener('submit', event => { + event.preventDefault(); - const target = assertType(event.target, HTMLFormElement); - const [ csrf, method ] = target.querySelectorAll('input'); + const target = assertType(event.target, HTMLFormElement); + const [csrf, method] = target.querySelectorAll('input'); - expect(csrf.name).toBe('_csrf_token'); - expect(csrf.value).toBe(window.booru.csrfToken); + expect(csrf.name).toBe('_csrf_token'); + expect(csrf.value).toBe(window.booru.csrfToken); - expect(method.name).toBe('_method'); - expect(method.value).toBe(mockVerb); + expect(method.name).toBe('_method'); + expect(method.value).toBe(mockVerb); - resolve(); - }); + resolve(); + }); - submitA(); - })); + submitA(); + })); }); describe('form[data-remote]', () => { @@ -167,7 +169,7 @@ describe('Remote utilities', () => { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'XMLHttpRequest' + 'x-requested-with': 'XMLHttpRequest', }, body: new FormData(), }); @@ -183,25 +185,26 @@ describe('Remote utilities', () => { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'XMLHttpRequest' + 'x-requested-with': 'XMLHttpRequest', }, body: new FormData(), }); }); - it('should emit fetchcomplete event', () => new Promise(resolve => { - let form: HTMLFormElement | null = null; + it('should emit fetchcomplete event', () => + new Promise(resolve => { + let form: HTMLFormElement | null = null; - addOneShotEventListener('fetchcomplete', event => { - expect(event.target).toBe(form); - resolve(); - }); + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(form); + resolve(); + }); - form = submitForm(); - })); + form = submitForm(); + })); it('should reload the page on 300 multiple choices response', () => { - vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300})); + vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300 })); submitForm(); return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1)); @@ -267,7 +270,7 @@ describe('Form utilities', () => { form.insertAdjacentElement('beforeend', button); document.documentElement.insertAdjacentElement('beforeend', form); - return [ form, button ]; + return [form, button]; }; const submitText = 'Submit'; @@ -276,7 +279,7 @@ describe('Form utilities', () => { const loadingMarkup = 'Loading...'; it('should disable submit button containing a text child on click', () => { - const [ , button ] = createFormAndButton(submitText, loadingText); + const [, button] = createFormAndButton(submitText, loadingText); fireEvent.click(button); expect(button.textContent).toEqual(' Loading...'); @@ -284,7 +287,7 @@ describe('Form utilities', () => { }); it('should disable submit button containing element children on click', () => { - const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup); + const [, button] = createFormAndButton(submitMarkup, loadingMarkup); fireEvent.click(button); expect(button.innerHTML).toEqual(loadingMarkup); @@ -292,7 +295,7 @@ describe('Form utilities', () => { }); it('should not disable anything when the form is invalid', () => { - const [ form, button ] = createFormAndButton(submitText, loadingText); + const [form, button] = createFormAndButton(submitText, loadingText); form.insertAdjacentHTML('afterbegin', ''); fireEvent.click(button); @@ -301,7 +304,7 @@ describe('Form utilities', () => { }); it('should reset submit button containing a text child on completion', () => { - const [ form, button ] = createFormAndButton(submitText, loadingText); + const [form, button] = createFormAndButton(submitText, loadingText); fireEvent.click(button); fireEvent(form, new CustomEvent('reset', { bubbles: true })); @@ -310,7 +313,7 @@ describe('Form utilities', () => { }); it('should reset submit button containing element children on completion', () => { - const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup); + const [form, button] = createFormAndButton(submitMarkup, loadingMarkup); fireEvent.click(button); fireEvent(form, new CustomEvent('reset', { bubbles: true })); @@ -319,7 +322,7 @@ describe('Form utilities', () => { }); it('should reset disabled form elements on pageshow', () => { - const [ , button ] = createFormAndButton(submitText, loadingText); + const [, button] = createFormAndButton(submitText, loadingText); fireEvent.click(button); fireEvent(window, new CustomEvent('pageshow')); diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index 4f671666..06d14d64 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -29,12 +29,16 @@ describe('Image upload form', () => { let mockPng: File; let mockWebm: File; - beforeAll(async() => { + beforeAll(async () => { const mockPngPath = join(__dirname, 'upload-test.png'); const mockWebmPath = join(__dirname, 'upload-test.webm'); - mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { type: 'image/png' }); - mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { type: 'video/webm' }); + mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { + type: 'image/png', + }); + mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { + type: 'video/webm', + }); }); beforeAll(() => { @@ -47,7 +51,6 @@ describe('Image upload form', () => { fixEventListeners(window); - let form: HTMLFormElement; let imgPreviews: HTMLDivElement; let fileField: HTMLInputElement; @@ -63,8 +66,9 @@ describe('Image upload form', () => { }; beforeEach(() => { - document.documentElement.insertAdjacentHTML('beforeend', ` -
+ document.documentElement.insertAdjacentHTML( + 'beforeend', + `
@@ -74,8 +78,8 @@ describe('Image upload form', () => { -
- `); + `, + ); form = assertNotNull($('form')); imgPreviews = assertNotNull($('#js-image-upload-previews')); @@ -121,7 +125,7 @@ describe('Image upload form', () => { }); }); - it('should block navigation away after an image file is attached, but not after form submission', async() => { + it('should block navigation away after an image file is attached, but not after form submission', async () => { fireEvent.change(fileField, { target: { files: [mockPng] } }); await waitFor(() => { assertFetchButtonIsDisabled(); @@ -143,7 +147,7 @@ describe('Image upload form', () => { expect(fireEvent(window, succeededUnloadEvent)).toBe(true); }); - it('should scrape images when the fetch button is clicked', async() => { + it('should scrape images when the fetch button is clicked', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 })); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 1551a6f8..1a95fb04 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -10,12 +10,12 @@ import store from './utils/store'; const cache = {}; /** @type {HTMLInputElement} */ let inputField, - /** @type {string} */ - originalTerm, - /** @type {string} */ - originalQuery, - /** @type {TermContext} */ - selectedTerm; + /** @type {string} */ + originalTerm, + /** @type {string} */ + originalQuery, + /** @type {TermContext} */ + selectedTerm; function removeParent() { const parent = document.querySelector('.autocomplete'); @@ -52,15 +52,16 @@ function applySelectedValue(selection) { } function changeSelected(firstOrLast, current, sibling) { - if (current && sibling) { // if the currently selected item has a sibling, move selection to it + if (current && sibling) { + // if the currently selected item has a sibling, move selection to it current.classList.remove('autocomplete__item--selected'); sibling.classList.add('autocomplete__item--selected'); - } - else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term + } else if (current) { + // if the next keypress will take the user outside the list, restore the unautocompleted term restoreOriginalValue(); removeSelected(); - } - else if (firstOrLast) { // if no item in the list is selected, select the first or last + } else if (firstOrLast) { + // if no item in the list is selected, select the first or last firstOrLast.classList.add('autocomplete__item--selected'); } } @@ -74,15 +75,16 @@ function isSelectionOutsideCurrentTerm() { function keydownHandler(event) { const selected = document.querySelector('.autocomplete__item--selected'), - firstItem = document.querySelector('.autocomplete__item:first-of-type'), - lastItem = document.querySelector('.autocomplete__item:last-of-type'); + firstItem = document.querySelector('.autocomplete__item:first-of-type'), + lastItem = document.querySelector('.autocomplete__item:last-of-type'); if (isSearchField()) { // Prevent submission of the search field when Enter was hit if (selected && event.keyCode === 13) event.preventDefault(); // Enter // Close autocompletion popup when text cursor is outside current tag - if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight + if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { + // ArrowLeft || ArrowRight requestAnimationFrame(() => { if (isSelectionOutsideCurrentTerm()) removeParent(); }); @@ -92,7 +94,8 @@ function keydownHandler(event) { if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma - if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown + if (event.keyCode === 38 || event.keyCode === 40) { + // ArrowUp || ArrowDown const newSelected = document.querySelector('.autocomplete__item--selected'); if (newSelected) applySelectedValue(newSelected.dataset.value); event.preventDefault(); @@ -123,8 +126,8 @@ function createItem(list, suggestion) { type: 'click', label: suggestion.label, value: suggestion.value, - } - }) + }, + }), ); }); @@ -133,7 +136,7 @@ function createItem(list, suggestion) { function createList(suggestions) { const parent = document.querySelector('.autocomplete'), - list = document.createElement('ul'); + list = document.createElement('ul'); list.className = 'autocomplete__list'; suggestions.forEach(suggestion => createItem(list, suggestion)); @@ -193,8 +196,7 @@ function toggleSearchAutocomplete() { for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) { if (enable) { searchField.autocomplete = 'off'; - } - else { + } else { searchField.removeAttribute('data-ac'); searchField.autocomplete = 'on'; } @@ -230,12 +232,13 @@ function listenAutocomplete() { } originalTerm = selectedTerm[1].toLowerCase(); - } - else { + } else { originalTerm = `${inputField.value}`.toLowerCase(); } - const suggestions = localAc.topK(originalTerm, suggestionsCount).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); + const suggestions = localAc + .topK(originalTerm, suggestionsCount) + .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { return showAutocomplete(suggestions, originalTerm, event.target); @@ -248,13 +251,12 @@ function listenAutocomplete() { originalTerm = inputField.value; const fetchedTerm = inputField.value; - const {ac, acMinLength, acSource} = inputField.dataset; + const { ac, acMinLength, acSource } = inputField.dataset; - if (ac && acSource && (fetchedTerm.length >= acMinLength)) { + if (ac && acSource && fetchedTerm.length >= acMinLength) { if (cache[fetchedTerm]) { showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target); - } - else { + } else { // inputField could get overwritten while the suggestions are being fetched - use event.target getSuggestions(fetchedTerm).then(suggestions => { if (fetchedTerm === event.target.value) { @@ -282,7 +284,9 @@ function listenAutocomplete() { fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' }) .then(handleError) .then(resp => resp.arrayBuffer()) - .then(buf => localAc = new LocalAutocompleter(buf)); + .then(buf => { + localAc = new LocalAutocompleter(buf); + }); } } diff --git a/assets/js/booru.js b/assets/js/booru.js index 3e1e7dac..3c58a7cf 100644 --- a/assets/js/booru.js +++ b/assets/js/booru.js @@ -23,7 +23,7 @@ function persistTag(tagData) { */ function isStale(tag) { const now = new Date().getTime() / 1000; - return tag.fetchedAt === null || tag.fetchedAt < (now - 604800); + return tag.fetchedAt === null || tag.fetchedAt < now - 604800; } function clearTags() { @@ -40,11 +40,13 @@ function clearTags() { */ function isValidStoredTag(value) { if (value !== null && 'id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) { - return typeof value.id === 'number' - && typeof value.name === 'string' - && typeof value.images === 'number' - && (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string') - && (value.fetchedAt === null || typeof value.fetchedAt === 'number'); + return ( + typeof value.id === 'number' && + typeof value.name === 'string' && + typeof value.images === 'number' && + (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string') && + (value.fetchedAt === null || typeof value.fetchedAt === 'number') + ); } return false; @@ -112,17 +114,18 @@ function verifyTagsVersion(latest) { } function initializeFilters() { - const tags = window.booru.spoileredTagList - .concat(window.booru.hiddenTagList) - .filter((a, b, c) => c.indexOf(a) === b); + const tags = window.booru.spoileredTagList.concat(window.booru.hiddenTagList).filter((a, b, c) => c.indexOf(a) === b); verifyTagsVersion(window.booru.tagsVersion); fetchNewOrStaleTags(tags); } function unmarshal(data) { - try { return JSON.parse(data); } - catch { return data; } + try { + return JSON.parse(data); + } catch { + return data; + } } function loadBooruData() { diff --git a/assets/js/boorujs.js b/assets/js/boorujs.js index 6763358f..9d8a71c6 100644 --- a/assets/js/boorujs.js +++ b/assets/js/boorujs.js @@ -9,42 +9,44 @@ import { fetchHtml, handleError } from './utils/requests'; import { showBlock } from './utils/image'; import { addTag } from './tagsinput'; +/* eslint-disable prettier/prettier */ + // Event types and any qualifying conditions - return true to not run action const types = { - click(event) { return event.button !== 0; /* Left-click only */ }, - - change() { /* No qualifier */ }, - + click(event) { return event.button !== 0; /* Left-click only */ }, + change() { /* No qualifier */ }, fetchcomplete() { /* No qualifier */ }, }; const actions = { - hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); }, - - tabHide(data) { selectorCbChildren(data.base, data.value, el => el.classList.add('hidden')); }, - - show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); }, - - toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); }, - - submit(data) { selectorCb(data.base, data.value, el => el.submit()); }, - - disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); }, + hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); }, + show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); }, + toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); }, + submit(data) { selectorCb(data.base, data.value, el => el.submit()); }, + disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); }, + focus(data) { document.querySelector(data.value).focus(); }, + unfilter(data) { showBlock(data.el.closest('.image-show-container')); }, + tabHide(data) { selectorCbChildren(data.base, data.value, el => el.classList.add('hidden')); }, + preventdefault() { /* The existence of this entry is enough */ }, copy(data) { document.querySelector(data.value).select(); document.execCommand('copy'); }, - inputvalue(data) { document.querySelector(data.value).value = data.el.dataset.setValue; }, + inputvalue(data) { + document.querySelector(data.value).value = data.el.dataset.setValue; + }, - selectvalue(data) { document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; }, + selectvalue(data) { + document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; + }, - checkall(data) { $$(`${data.value} input[type=checkbox]`).forEach(c => { c.checked = !c.checked; }); }, - - focus(data) { document.querySelector(data.value).focus(); }, - - preventdefault() { /* The existence of this entry is enough */ }, + checkall(data) { + $$(`${data.value} input[type=checkbox]`).forEach(c => { + c.checked = !c.checked; + }); + }, addtag(data) { addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName); @@ -75,13 +77,11 @@ const actions = { .then(() => newTab.dataset.loaded = true) .catch(() => newTab.textContent = 'Error!'); } - }, - - unfilter(data) { showBlock(data.el.closest('.image-show-container')); }, - }; +/* eslint-enable prettier/prettier */ + // Use this function to apply a callback to elements matching the selectors function selectorCb(base = document, selector, cb) { [].forEach.call(base.querySelectorAll(selector), cb); @@ -100,16 +100,14 @@ function selectorCbChildren(base = document, selector, cb) { function matchAttributes(event) { if (!types[event.type](event)) { for (const action in actions) { - const attr = `data-${event.type}-${action.toLowerCase()}`, - el = event.target && event.target.closest(`[${attr}]`), - value = el && el.getAttribute(attr); + el = event.target && event.target.closest(`[${attr}]`), + value = el && el.getAttribute(attr); if (el) { // Return true if you don't want to preventDefault actions[action]({ attr, el, value }) || event.preventDefault(); } - } } } diff --git a/assets/js/burger.ts b/assets/js/burger.ts index d82d032f..58c416bb 100644 --- a/assets/js/burger.ts +++ b/assets/js/burger.ts @@ -59,8 +59,7 @@ export function setupBurgerMenu() { if (content.classList.contains('open')) { close(burger, content, body, root); - } - else { + } else { open(burger, content, body, root); } }); diff --git a/assets/js/captcha.ts b/assets/js/captcha.ts index 44d0d9b6..5c83cd14 100644 --- a/assets/js/captcha.ts +++ b/assets/js/captcha.ts @@ -5,8 +5,8 @@ import { clearEl, makeEl } from './utils/dom'; function insertCaptcha(_event: Event, target: HTMLInputElement) { const parentElement = assertNotNull(target.parentElement); - const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true}); - const frame = makeEl('div', {className: 'h-captcha'}); + const script = makeEl('script', { src: 'https://hcaptcha.com/1/api.js', async: true, defer: true }); + const frame = makeEl('div', { className: 'h-captcha' }); frame.dataset.sitekey = target.dataset.sitekey; @@ -17,5 +17,5 @@ function insertCaptcha(_event: Event, target: HTMLInputElement) { } export function bindCaptchaLinks() { - delegate(document, 'click', {'.js-captcha': leftClick(insertCaptcha)}); + delegate(document, 'click', { '.js-captcha': leftClick(insertCaptcha) }); } diff --git a/assets/js/comment.js b/assets/js/comment.js index 5dbdfac6..2b300568 100644 --- a/assets/js/comment.js +++ b/assets/js/comment.js @@ -8,22 +8,19 @@ import { fetchHtml } from './utils/requests'; import { timeAgo } from './timeago'; function handleError(response) { - const errorMessage = '
Comment failed to load!
'; if (!response.ok) { return errorMessage; } return response.text(); - } function commentPosted(response) { - - const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'), - commentEditForm = $('#js-comment-form'), - container = document.getElementById('comments'), - requestOk = response.ok; + const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'), + commentEditForm = $('#js-comment-form'), + container = document.getElementById('comments'), + requestOk = response.ok; commentEditTab.click(); commentEditForm.reset(); @@ -32,26 +29,22 @@ function commentPosted(response) { response.text().then(text => { if (text.includes('
')) { window.location.reload(); - } - else { + } else { displayComments(container, text); } }); - } - else { + } else { window.location.reload(); window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash) } - } function loadParentPost(event) { - const clickedLink = event.target, - // Find the comment containing the link that was clicked - fullComment = clickedLink.closest('article.block'), - // Look for a potential image and comment ID - commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href')); + // Find the comment containing the link that was clicked + fullComment = clickedLink.closest('article.block'), + // Look for a potential image and comment ID + commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href')); // If the clicked link is already active, just clear the parent comments if (clickedLink.classList.contains('active_reply_link')) { @@ -61,9 +54,8 @@ function loadParentPost(event) { } if (commentMatches) { - // If the regex matched, get the image and comment ID - const [ , imageId, commentId ] = commentMatches; + const [, imageId, commentId] = commentMatches; fetchHtml(`/images/${imageId}/comments/${commentId}`) .then(handleError) @@ -73,13 +65,10 @@ function loadParentPost(event) { }); return true; - } - } function insertParentPost(data, clickedLink, fullComment) { - // Add the 'subthread' class to the comment with the clicked link fullComment.classList.add('subthread'); @@ -98,11 +87,9 @@ function insertParentPost(data, clickedLink, fullComment) { // Filter images (if any) in the loaded comment filterNode(fullComment.previousSibling); - } function clearParentPost(clickedLink, fullComment) { - // Remove any previous siblings with the class fetched-comment while (fullComment.previousSibling && fullComment.previousSibling.classList.contains('fetched-comment')) { fullComment.previousSibling.parentNode.removeChild(fullComment.previousSibling); @@ -117,11 +104,9 @@ function clearParentPost(clickedLink, fullComment) { if (!fullComment.classList.contains('fetched-comment')) { fullComment.classList.remove('subthread'); } - } function displayComments(container, commentsHtml) { - container.innerHTML = commentsHtml; // Execute timeago on comments @@ -129,21 +114,21 @@ function displayComments(container, commentsHtml) { // Filter images in the comments filterNode(container); - } function loadComments(event) { - const container = document.getElementById('comments'), - hasHref = event.target && event.target.getAttribute('href'), - hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/), - getURL = hasHref || (hasHash ? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}` - : container.dataset.currentUrl); + hasHref = event.target && event.target.getAttribute('href'), + hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/), + getURL = + hasHref || + (hasHash + ? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}` + : container.dataset.currentUrl); fetchHtml(getURL) .then(handleError) .then(data => { - displayComments(container, data); // Make sure the :target CSS selector applies to the inserted content @@ -155,21 +140,19 @@ function loadComments(event) { }); return true; - } function setupComments() { - const comments = document.getElementById('comments'), - hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/), - targetOnPage = hasHash ? Boolean($(window.location.hash)) : true; + const comments = document.getElementById('comments'), + hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/), + targetOnPage = hasHash ? Boolean($(window.location.hash)) : true; // Load comments over AJAX if we are on a page with element #comments if (comments) { if (!comments.dataset.loaded || !targetOnPage) { // There is no event associated with the initial load, so use false loadComments(false); - } - else { + } else { filterNode(comments); } } @@ -182,7 +165,8 @@ function setupComments() { }; document.addEventListener('click', event => { - if (event.button === 0) { // Left-click only + if (event.button === 0) { + // Left-click only for (const target in targets) { if (event.target && event.target.closest(target)) { targets[target](event) && event.preventDefault(); diff --git a/assets/js/duplicate_reports.ts b/assets/js/duplicate_reports.ts index 55cdfeb1..623aaf8b 100644 --- a/assets/js/duplicate_reports.ts +++ b/assets/js/duplicate_reports.ts @@ -15,7 +15,7 @@ export function setupDupeReports() { } function setupSwipe(swipe: SVGSVGElement) { - const [ clip, divider ] = $$('#clip rect, #divider', swipe); + const [clip, divider] = $$('#clip rect, #divider', swipe); const { width } = swipe.viewBox.baseVal; function moveDivider({ clientX }: MouseEvent) { diff --git a/assets/js/fp.ts b/assets/js/fp.ts index 65f0c583..8302029d 100644 --- a/assets/js/fp.ts +++ b/assets/js/fp.ts @@ -11,19 +11,19 @@ const storageKey = 'cached_ses_value'; declare global { interface Keyboard { - getLayoutMap: () => Promise> + getLayoutMap: () => Promise>; } interface UserAgentData { - brands: [{brand: string, version: string}], - mobile: boolean, - platform: string, + brands: [{ brand: string; version: string }]; + mobile: boolean; + platform: string; } interface Navigator { - deviceMemory: number | undefined, - keyboard: Keyboard | undefined, - userAgentData: UserAgentData | undefined, + deviceMemory: number | undefined; + keyboard: Keyboard | undefined; + userAgentData: UserAgentData | undefined; } } @@ -45,10 +45,10 @@ function cyrb53(str: string, seed: number = 0x16fe7b0a): number { h2 = Math.imul(h2 ^ ch, 1597334677); } - h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507); - h1 ^= Math.imul(h2 ^ h2 >>> 13, 3266489909); - h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507); - h2 ^= Math.imul(h1 ^ h1 >>> 13, 3266489909); + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); } @@ -161,9 +161,7 @@ async function createFp(): Promise { new Date().getTimezoneOffset().toString(), ]; - return cyrb53(prints.join('')) - .toString(16) - .padStart(14, '0'); + return cyrb53(prints.join('')).toString(16).padStart(14, '0'); } /** diff --git a/assets/js/galleries.ts b/assets/js/galleries.ts index 1556d8a0..5a681189 100644 --- a/assets/js/galleries.ts +++ b/assets/js/galleries.ts @@ -11,7 +11,7 @@ import { fetchJson } from './utils/requests'; export function setupGalleryEditing() { if (!$('.rearrange-button')) return; - const [ rearrangeEl, saveEl ] = $$('.rearrange-button'); + const [rearrangeEl, saveEl] = $$('.rearrange-button'); const sortableEl = assertNotNull($('#sortable')); const containerEl = assertNotNull($('.js-resizable-media-container')); @@ -22,7 +22,9 @@ export function setupGalleryEditing() { initDraggables(); - $$('.media-box', containerEl).forEach(i => i.draggable = true); + for (const mediaBox of $$('.media-box', containerEl)) { + mediaBox.draggable = true; + } rearrangeEl.addEventListener('click', () => { sortableEl.classList.add('editing'); @@ -33,8 +35,9 @@ export function setupGalleryEditing() { sortableEl.classList.remove('editing'); containerEl.classList.remove('drag-container'); - newImages = $$('.image-container', containerEl) - .map(i => parseInt(assertNotUndefined(i.dataset.imageId), 10)); + newImages = $$('.image-container', containerEl).map(i => + parseInt(assertNotUndefined(i.dataset.imageId), 10), + ); // If nothing changed, don't bother. if (arraysEqual(newImages, oldImages)) return; @@ -43,8 +46,9 @@ export function setupGalleryEditing() { fetchJson('PATCH', reorderPath, { image_ids: newImages, - - // copy the array again so that we have the newly updated set - }).then(() => oldImages = newImages.slice()); + }).then(() => { + // copy the array again so that we have the newly updated set + oldImages = newImages.slice(); + }); }); } diff --git a/assets/js/image_expansion.js b/assets/js/image_expansion.js index 7bd6cb26..10f1130e 100644 --- a/assets/js/image_expansion.js +++ b/assets/js/image_expansion.js @@ -5,7 +5,7 @@ const imageVersions = { // [width, height] small: [320, 240], medium: [800, 600], - large: [1280, 1024] + large: [1280, 1024], }; /** @@ -14,7 +14,7 @@ const imageVersions = { */ function selectVersion(imageWidth, imageHeight, imageSize, imageMime) { let viewWidth = document.documentElement.clientWidth, - viewHeight = document.documentElement.clientHeight; + viewHeight = document.documentElement.clientHeight; // load hires if that's what you asked for if (store.get('serve_hidpi')) { @@ -31,9 +31,9 @@ function selectVersion(imageWidth, imageHeight, imageSize, imageMime) { // .find() is not supported in older browsers, using a loop for (let i = 0, versions = Object.keys(imageVersions); i < versions.length; ++i) { const version = versions[i], - dimensions = imageVersions[version], - versionWidth = Math.min(imageWidth, dimensions[0]), - versionHeight = Math.min(imageHeight, dimensions[1]); + dimensions = imageVersions[version], + versionWidth = Math.min(imageWidth, dimensions[0]), + versionHeight = Math.min(imageHeight, dimensions[1]); if (versionWidth > viewWidth || versionHeight > viewHeight) { return version; } @@ -57,11 +57,11 @@ function selectVersion(imageWidth, imageHeight, imageSize, imageMime) { */ function pickAndResize(elem) { const imageWidth = parseInt(elem.dataset.width, 10), - imageHeight = parseInt(elem.dataset.height, 10), - imageSize = parseInt(elem.dataset.imageSize, 10), - imageMime = elem.dataset.mimeType, - scaled = elem.dataset.scaled, - uris = JSON.parse(elem.dataset.uris); + imageHeight = parseInt(elem.dataset.height, 10), + imageSize = parseInt(elem.dataset.imageSize, 10), + imageMime = elem.dataset.mimeType, + scaled = elem.dataset.scaled, + uris = JSON.parse(elem.dataset.uris); let version = 'full'; @@ -91,7 +91,8 @@ function pickAndResize(elem) { if (imageFormat === 'mp4') { elem.classList.add('full-height'); - elem.insertAdjacentHTML('afterbegin', + elem.insertAdjacentHTML( + 'afterbegin', `` + `, ); - } - else if (imageFormat === 'webm') { - elem.insertAdjacentHTML('afterbegin', + } else if (imageFormat === 'webm') { + elem.insertAdjacentHTML( + 'afterbegin', `` + `, ); const video = elem.querySelector('video'); if (scaled === 'true') { video.className = 'image-scaled'; - } - else if (scaled === 'partscaled') { + } else if (scaled === 'partscaled') { video.className = 'image-partscaled'; } - } - else { + } else { let image; if (scaled === 'true') { image = ``; - } - else if (scaled === 'partscaled') { + } else if (scaled === 'partscaled') { image = ``; - } - else { + } else { image = ``; } if (elem.innerHTML === image) return; @@ -148,11 +145,9 @@ function bindImageForClick(target) { target.addEventListener('click', () => { if (target.getAttribute('data-scaled') === 'true') { target.setAttribute('data-scaled', 'partscaled'); - } - else if (target.getAttribute('data-scaled') === 'partscaled') { + } else if (target.getAttribute('data-scaled') === 'partscaled') { target.setAttribute('data-scaled', 'false'); - } - else { + } else { target.setAttribute('data-scaled', 'true'); } diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts index add108c9..8ea43a54 100644 --- a/assets/js/imagesclientside.ts +++ b/assets/js/imagesclientside.ts @@ -12,12 +12,7 @@ import { AstMatcher } from './query/types'; type CallbackType = 'tags' | 'complex'; type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void; -function run( - img: HTMLDivElement, - tags: TagData[], - complex: AstMatcher, - runCallback: RunCallback -): boolean { +function run(img: HTMLDivElement, tags: TagData[], complex: AstMatcher, runCallback: RunCallback): boolean { const hit = (() => { // Check tags array first to provide more precise filter explanations const hitTags = imageHitsTags(img, tags); @@ -56,47 +51,46 @@ function bannerImage(tagsHit: TagData[]) { // TODO: this approach is not suitable for translations because it depends on // markup embedded in the page adjacent to this text -/* eslint-disable indent */ - function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { - const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` - : '[HIDDEN] (Complex Filter)'; + const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` : '[HIDDEN] (Complex Filter)'; hideThumb(img, bannerImage(tagsHit), bannerText); } function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { - const bannerText = type === 'tags' ? displayTags(tagsHit) - : '(Complex Filter)'; + const bannerText = type === 'tags' ? displayTags(tagsHit) : '(Complex Filter)'; spoilerThumb(img, bannerImage(tagsHit), bannerText); } function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { - const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` - : 'This image was hidden by a complex tag expression in '; + const bannerText = + type === 'tags' + ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` + : 'This image was hidden by a complex tag expression in '; spoilerBlock(img, bannerImage(tagsHit), bannerText); } function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { - const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` - : 'This image was spoilered by a complex tag expression in '; + const bannerText = + type === 'tags' + ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` + : 'This image was spoilered by a complex tag expression in '; spoilerBlock(img, bannerImage(tagsHit), bannerText); } -/* eslint-enable indent */ - export function filterNode(node: Pick) { - const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); + const hiddenTags = getHiddenTags(), + spoileredTags = getSpoileredTags(); const { hiddenFilter, spoileredFilter } = window.booru; // Image thumb boxes with vote and fave buttons on them $$('.image-container', node) - .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped)) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped)) .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped)) .forEach(img => showThumb(img)); // Individual image pages and images in posts/comments $$('.image-show-container', node) - .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped)) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped)) .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped)) .forEach(img => showBlock(img)); } diff --git a/assets/js/input-duplicator.ts b/assets/js/input-duplicator.ts index e82c892d..57cbc1f4 100644 --- a/assets/js/input-duplicator.ts +++ b/assets/js/input-duplicator.ts @@ -13,7 +13,7 @@ export function inputDuplicatorCreator({ addButtonSelector, fieldSelector, maxInputCountSelector, - removeButtonSelector + removeButtonSelector, }: InputDuplicatorOptions) { const addButton = $(addButtonSelector); if (!addButton) { @@ -35,10 +35,9 @@ export function inputDuplicatorCreator({ }; delegate(form, 'click', { - [removeButtonSelector]: leftClick(fieldRemover) + [removeButtonSelector]: leftClick(fieldRemover), }); - const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form)); const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10); diff --git a/assets/js/interactions.js b/assets/js/interactions.js index 95d3bad0..c9e05ef1 100644 --- a/assets/js/interactions.js +++ b/assets/js/interactions.js @@ -6,18 +6,22 @@ import { fetchJson } from './utils/requests'; import { $ } from './utils/dom'; const endpoints = { - vote(imageId) { return `/images/${imageId}/vote`; }, - fave(imageId) { return `/images/${imageId}/fave`; }, - hide(imageId) { return `/images/${imageId}/hide`; }, + vote(imageId) { + return `/images/${imageId}/vote`; + }, + fave(imageId) { + return `/images/${imageId}/fave`; + }, + hide(imageId) { + return `/images/${imageId}/hide`; + }, }; -const spoilerDownvoteMsg = - 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails'; +const spoilerDownvoteMsg = 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails'; /* Quick helper function to less verbosely iterate a QSA */ function onImage(id, selector, cb) { - [].forEach.call( - document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb); + [].forEach.call(document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb); } /* Since JS modifications to webpages, except form inputs, are not stored @@ -49,14 +53,18 @@ function uncacheStatus(imageId, interactionType) { } function setScore(imageId, data) { - onImage(imageId, '.score', - el => el.textContent = data.score); - onImage(imageId, '.favorites', - el => el.textContent = data.faves); - onImage(imageId, '.upvotes', - el => el.textContent = data.upvotes); - onImage(imageId, '.downvotes', - el => el.textContent = data.downvotes); + onImage(imageId, '.score', el => { + el.textContent = data.score; + }); + onImage(imageId, '.favorites', el => { + el.textContent = data.faves; + }); + onImage(imageId, '.upvotes', el => { + el.textContent = data.upvotes; + }); + onImage(imageId, '.downvotes', el => { + el.textContent = data.downvotes; + }); } /* These change the visual appearance of interaction links. @@ -64,48 +72,38 @@ function setScore(imageId, data) { function showUpvoted(imageId) { cacheStatus(imageId, 'voted', 'up'); - onImage(imageId, '.interaction--upvote', - el => el.classList.add('active')); + onImage(imageId, '.interaction--upvote', el => el.classList.add('active')); } function showDownvoted(imageId) { cacheStatus(imageId, 'voted', 'down'); - onImage(imageId, '.interaction--downvote', - el => el.classList.add('active')); + onImage(imageId, '.interaction--downvote', el => el.classList.add('active')); } function showFaved(imageId) { cacheStatus(imageId, 'faved', ''); - onImage(imageId, '.interaction--fave', - el => el.classList.add('active')); + onImage(imageId, '.interaction--fave', el => el.classList.add('active')); } function showHidden(imageId) { cacheStatus(imageId, 'hidden', ''); - onImage(imageId, '.interaction--hide', - el => el.classList.add('active')); + onImage(imageId, '.interaction--hide', el => el.classList.add('active')); } function resetVoted(imageId) { uncacheStatus(imageId, 'voted'); - - onImage(imageId, '.interaction--upvote', - el => el.classList.remove('active')); - - onImage(imageId, '.interaction--downvote', - el => el.classList.remove('active')); + onImage(imageId, '.interaction--upvote', el => el.classList.remove('active')); + onImage(imageId, '.interaction--downvote', el => el.classList.remove('active')); } function resetFaved(imageId) { uncacheStatus(imageId, 'faved'); - onImage(imageId, '.interaction--fave', - el => el.classList.remove('active')); + onImage(imageId, '.interaction--fave', el => el.classList.remove('active')); } function resetHidden(imageId) { uncacheStatus(imageId, 'hidden'); - onImage(imageId, '.interaction--hide', - el => el.classList.remove('active')); + onImage(imageId, '.interaction--hide', el => el.classList.remove('active')); } function interact(type, imageId, method, data = {}) { @@ -131,7 +129,6 @@ function displayInteractionSet(interactions) { } function loadInteractions() { - /* Set up the actual interactions */ displayInteractionSet(window.booru.interactions); displayInteractionSet(getCache()); @@ -143,66 +140,69 @@ function loadInteractions() { /* Users will blind downvote without this */ window.booru.imagesWithDownvotingDisabled.forEach(i => { onImage(i, '.interaction--downvote', a => { - // TODO Use a 'js-' class to target these instead const icon = a.querySelector('i') || a.querySelector('.oc-icon-small'); icon.setAttribute('title', spoilerDownvoteMsg); a.classList.add('disabled'); - a.addEventListener('click', event => { - event.stopPropagation(); - event.preventDefault(); - }, true); - + a.addEventListener( + 'click', + event => { + event.stopPropagation(); + event.preventDefault(); + }, + true, + ); }); }); - } const targets = { - /* Active-state targets first */ '.interaction--upvote.active'(imageId) { - interact('vote', imageId, 'DELETE') - .then(() => resetVoted(imageId)); + interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId)); }, '.interaction--downvote.active'(imageId) { - interact('vote', imageId, 'DELETE') - .then(() => resetVoted(imageId)); + interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId)); }, '.interaction--fave.active'(imageId) { - interact('fave', imageId, 'DELETE') - .then(() => resetFaved(imageId)); + interact('fave', imageId, 'DELETE').then(() => resetFaved(imageId)); }, '.interaction--hide.active'(imageId) { - interact('hide', imageId, 'DELETE') - .then(() => resetHidden(imageId)); + interact('hide', imageId, 'DELETE').then(() => resetHidden(imageId)); }, /* Inactive targets */ '.interaction--upvote:not(.active)'(imageId) { - interact('vote', imageId, 'POST', { up: true }) - .then(() => { resetVoted(imageId); showUpvoted(imageId); }); + interact('vote', imageId, 'POST', { up: true }).then(() => { + resetVoted(imageId); + showUpvoted(imageId); + }); }, '.interaction--downvote:not(.active)'(imageId) { - interact('vote', imageId, 'POST', { up: false }) - .then(() => { resetVoted(imageId); showDownvoted(imageId); }); + interact('vote', imageId, 'POST', { up: false }).then(() => { + resetVoted(imageId); + showDownvoted(imageId); + }); }, '.interaction--fave:not(.active)'(imageId) { - interact('fave', imageId, 'POST') - .then(() => { resetVoted(imageId); showFaved(imageId); showUpvoted(imageId); }); + interact('fave', imageId, 'POST').then(() => { + resetVoted(imageId); + showFaved(imageId); + showUpvoted(imageId); + }); }, '.interaction--hide:not(.active)'(imageId) { - interact('hide', imageId, 'POST') - .then(() => { showHidden(imageId); }); + interact('hide', imageId, 'POST').then(() => { + showHidden(imageId); + }); }, - }; function bindInteractions() { document.addEventListener('click', event => { - - if (event.button === 0) { // Is it a left-click? + if (event.button === 0) { + // Is it a left-click? for (const target in targets) { /* Event delegation doesn't quite grab what we want here. */ const link = event.target && event.target.closest(target); @@ -213,21 +213,20 @@ function bindInteractions() { } } } - }); } function loggedOutInteractions() { - [].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'), - a => a.setAttribute('href', '/sessions/new')); + [].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'), a => + a.setAttribute('href', '/sessions/new'), + ); } function setupInteractions() { if (window.booru.userIsSignedIn) { bindInteractions(); loadInteractions(); - } - else { + } else { loggedOutInteractions(); } } diff --git a/assets/js/markdowntoolbar.js b/assets/js/markdowntoolbar.js index 0c8c7d8a..534388f6 100644 --- a/assets/js/markdowntoolbar.js +++ b/assets/js/markdowntoolbar.js @@ -7,19 +7,19 @@ import { $, $$ } from './utils/dom'; const markdownSyntax = { bold: { action: wrapSelection, - options: { prefix: '**', shortcutKey: 'b' } + options: { prefix: '**', shortcutKey: 'b' }, }, italics: { action: wrapSelection, - options: { prefix: '*', shortcutKey: 'i' } + options: { prefix: '*', shortcutKey: 'i' }, }, under: { action: wrapSelection, - options: { prefix: '__', shortcutKey: 'u' } + options: { prefix: '__', shortcutKey: 'u' }, }, spoiler: { action: wrapSelection, - options: { prefix: '||', shortcutKey: 's' } + options: { prefix: '||', shortcutKey: 's' }, }, code: { action: wrapSelectionOrLines, @@ -29,57 +29,56 @@ const markdownSyntax = { prefixMultiline: '```\n', suffixMultiline: '\n```', singleWrap: true, - shortcutKey: 'e' - } + shortcutKey: 'e', + }, }, strike: { action: wrapSelection, - options: { prefix: '~~' } + options: { prefix: '~~' }, }, superscript: { action: wrapSelection, - options: { prefix: '^' } + options: { prefix: '^' }, }, subscript: { action: wrapSelection, - options: { prefix: '%' } + options: { prefix: '%' }, }, quote: { action: wrapLines, - options: { prefix: '> ' } + options: { prefix: '> ' }, }, link: { action: insertLink, - options: { shortcutKey: 'l' } + options: { shortcutKey: 'l' }, }, image: { action: insertLink, - options: { image: true, shortcutKey: 'k' } + options: { image: true, shortcutKey: 'k' }, }, escape: { action: escapeSelection, - options: { escapeChar: '\\' } - } + options: { escapeChar: '\\' }, + }, }; function getSelections(textarea, linesOnly = false) { let { selectionStart, selectionEnd } = textarea, - selection = textarea.value.substring(selectionStart, selectionEnd), - leadingSpace = '', - trailingSpace = '', - caret; + selection = textarea.value.substring(selectionStart, selectionEnd), + leadingSpace = '', + trailingSpace = '', + caret; const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly; if (processLinesOnly) { const explorer = /\n/g; let startNewlineIndex = 0, - endNewlineIndex = textarea.value.length; + endNewlineIndex = textarea.value.length; while (explorer.exec(textarea.value)) { const { lastIndex } = explorer; if (lastIndex <= selectionStart) { startNewlineIndex = lastIndex; - } - else if (lastIndex > selectionEnd) { + } else if (lastIndex > selectionEnd) { endNewlineIndex = lastIndex - 1; break; } @@ -96,8 +95,7 @@ function getSelections(textarea, linesOnly = false) { } selectionEnd = endNewlineIndex; selection = textarea.value.substring(selectionStart, selectionEnd); - } - else { + } else { // Deselect trailing space and line break for (caret = selection.length - 1; caret > 0; caret--) { if (selection[caret] !== ' ' && selection[caret] !== '\n') break; @@ -117,22 +115,23 @@ function getSelections(textarea, linesOnly = false) { processLinesOnly, selectedText: selection, beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace, - afterSelection: trailingSpace + textarea.value.substring(selectionEnd) + afterSelection: trailingSpace + textarea.value.substring(selectionEnd), }; } function transformSelection(textarea, transformer, eachLine) { const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine), - // For long comments, record scrollbar position to restore it later - { scrollTop } = textarea; + // For long comments, record scrollbar position to restore it later + { scrollTop } = textarea; const { newText, caretOffset } = transformer(selectedText, processLinesOnly); textarea.value = beforeSelection + newText + afterSelection; - const newSelectionStart = caretOffset >= 1 - ? beforeSelection.length + caretOffset - : textarea.value.length - afterSelection.length - caretOffset; + const newSelectionStart = + caretOffset >= 1 + ? beforeSelection.length + caretOffset + : textarea.value.length - afterSelection.length - caretOffset; textarea.selectionStart = newSelectionStart; textarea.selectionEnd = newSelectionStart; @@ -151,7 +150,7 @@ function insertLink(textarea, options) { } const prefix = options.image ? '![' : '[', - suffix = `](${hyperlink})`; + suffix = `](${hyperlink})`; wrapSelection(textarea, { prefix, suffix }); } @@ -159,7 +158,7 @@ function insertLink(textarea, options) { function wrapSelection(textarea, options) { transformSelection(textarea, selectedText => { const { text = selectedText, prefix = '', suffix = options.prefix } = options, - emptyText = text === ''; + emptyText = text === ''; let newText = text; if (!emptyText) { @@ -172,26 +171,33 @@ function wrapSelection(textarea, options) { return { newText, - caretOffset: emptyText ? prefix.length : newText.length + caretOffset: emptyText ? prefix.length : newText.length, }; }); } function wrapLines(textarea, options, eachLine = true) { - transformSelection(textarea, (selectedText, processLinesOnly) => { - const { text = selectedText, singleWrap = false } = options, - prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', - suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', - emptyText = text === ''; - let newText = singleWrap - ? prefix + text.trim() + suffix - : text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n'); + transformSelection( + textarea, + (selectedText, processLinesOnly) => { + const { text = selectedText, singleWrap = false } = options, + prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', + suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', + emptyText = text === ''; + let newText = singleWrap + ? prefix + text.trim() + suffix + : text + .split(/\n/g) + .map(line => prefix + line.trim() + suffix) + .join('\n'); - // Force a space at the end of lines with only blockquote markers - newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> '); + // Force a space at the end of lines with only blockquote markers + newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> '); - return { newText, caretOffset: emptyText ? prefix.length : newText.length }; - }, eachLine); + return { newText, caretOffset: emptyText ? prefix.length : newText.length }; + }, + eachLine, + ); } function wrapSelectionOrLines(textarea, options) { @@ -201,7 +207,7 @@ function wrapSelectionOrLines(textarea, options) { function escapeSelection(textarea, options) { transformSelection(textarea, selectedText => { const { text = selectedText } = options, - emptyText = text === ''; + emptyText = text === ''; if (emptyText) return; @@ -209,7 +215,7 @@ function escapeSelection(textarea, options) { return { newText, - caretOffset: newText.length + caretOffset: newText.length, }; }); } @@ -218,20 +224,40 @@ function clickHandler(event) { const button = event.target.closest('.communication__toolbar__button'); if (!button) return; const toolbar = button.closest('.communication__toolbar'), - // There may be multiple toolbars present on the page, - // in the case of image pages with description edit active - // we target the textarea that shares the same parent as the toolbar - textarea = $('.js-toolbar-input', toolbar.parentNode), - id = button.dataset.syntaxId; + // There may be multiple toolbars present on the page, + // in the case of image pages with description edit active + // we target the textarea that shares the same parent as the toolbar + textarea = $('.js-toolbar-input', toolbar.parentNode), + id = button.dataset.syntaxId; markdownSyntax[id].action(textarea, markdownSyntax[id].options); textarea.focus(); } +function canAcceptShortcut(event) { + let ctrl, otherModifier; + + switch (window.navigator.platform) { + case 'MacIntel': + ctrl = event.metaKey; + otherModifier = event.ctrlKey || event.shiftKey || event.altKey; + break; + default: + ctrl = event.ctrlKey; + otherModifier = event.metaKey || event.shiftKey || event.altKey; + break; + } + + return ctrl && !otherModifier; +} + function shortcutHandler(event) { - if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return; + if (!canAcceptShortcut(event)) { + return; + } + const textarea = event.target, - key = event.key.toLowerCase(); + key = event.key.toLowerCase(); for (const id in markdownSyntax) { if (key === markdownSyntax[id].options.shortcutKey) { diff --git a/assets/js/misc.ts b/assets/js/misc.ts index 3de3ef86..430701ce 100644 --- a/assets/js/misc.ts +++ b/assets/js/misc.ts @@ -9,10 +9,10 @@ import '../types/ujs'; let touchMoved = false; -function formResult({target, detail}: FetchcompleteEvent) { +function formResult({ target, detail }: FetchcompleteEvent) { const elements: Record = { '#description-form': '.image-description', - '#uploader-form': '.image_uploader' + '#uploader-form': '.image_uploader', }; function showResult(formEl: HTMLFormElement, resultEl: HTMLElement, response: string) { @@ -25,7 +25,7 @@ function formResult({target, detail}: FetchcompleteEvent) { }); } - for (const [ formSelector, resultSelector ] of Object.entries(elements)) { + for (const [formSelector, resultSelector] of Object.entries(elements)) { if (target.matches(formSelector)) { const form = assertType(target, HTMLFormElement); const result = assertNotNull($(resultSelector)); @@ -91,5 +91,7 @@ export function setupEvents() { document.addEventListener('fetchcomplete', formResult); document.addEventListener('click', revealSpoiler); document.addEventListener('touchend', revealSpoiler); - document.addEventListener('touchmove', () => touchMoved = true); + document.addEventListener('touchmove', () => { + touchMoved = true; + }); } diff --git a/assets/js/notifications.ts b/assets/js/notifications.ts index d76cf533..d4446102 100644 --- a/assets/js/notifications.ts +++ b/assets/js/notifications.ts @@ -8,8 +8,8 @@ import { delegate } from './utils/events'; import { assertNotNull, assertNotUndefined } from './utils/assert'; import store from './utils/store'; -const NOTIFICATION_INTERVAL = 600000, - NOTIFICATION_EXPIRES = 300000; +const NOTIFICATION_INTERVAL = 600000; +const NOTIFICATION_EXPIRES = 300000; function bindSubscriptionLinks() { delegate(document, 'fetchcomplete', { @@ -19,7 +19,7 @@ function bindSubscriptionLinks() { event.detail.text().then(text => { target.outerHTML = text; }); - } + }, }); } diff --git a/assets/js/pmwarning.ts b/assets/js/pmwarning.ts index 23772dff..9068d1b3 100644 --- a/assets/js/pmwarning.ts +++ b/assets/js/pmwarning.ts @@ -18,8 +18,7 @@ export function warnAboutPMs() { if (value.match(imageEmbedRegex)) { showEl(warning); - } - else if (!warning.classList.contains('hidden')) { + } else { hideEl(warning); } }); diff --git a/assets/js/preview.js b/assets/js/preview.js index a3968762..21dc43a4 100644 --- a/assets/js/preview.js +++ b/assets/js/preview.js @@ -110,11 +110,12 @@ function setupPreviews() { // Fire handler for automatic resizing if textarea contains text on page load (e.g. editing) if (textarea.value) textarea.dispatchEvent(new Event('change')); - previewAnon && previewAnon.addEventListener('click', () => { - if (previewContent.classList.contains('hidden')) return; + previewAnon && + previewAnon.addEventListener('click', () => { + if (previewContent.classList.contains('hidden')) return; - updatePreview(); - }); + updatePreview(); + }); document.addEventListener('click', event => { if (event.target && event.target.closest('.post-reply')) { diff --git a/assets/js/query/__tests__/date.spec.ts b/assets/js/query/__tests__/date.spec.ts index 0c205d4d..d9f8336d 100644 --- a/assets/js/query/__tests__/date.spec.ts +++ b/assets/js/query/__tests__/date.spec.ts @@ -97,7 +97,9 @@ describe('Date parsing', () => { }); it('should not match malformed absolute date expressions', () => { - expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow('Cannot parse date string: 2024-06-21T06:21:30+01:3020'); + expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow( + 'Cannot parse date string: 2024-06-21T06:21:30+01:3020', + ); }); it('should not match malformed relative date expressions', () => { diff --git a/assets/js/query/__tests__/user.spec.ts b/assets/js/query/__tests__/user.spec.ts index 52545d0c..044b13ea 100644 --- a/assets/js/query/__tests__/user.spec.ts +++ b/assets/js/query/__tests__/user.spec.ts @@ -4,10 +4,10 @@ describe('User field parsing', () => { beforeEach(() => { /* eslint-disable camelcase */ window.booru.interactions = [ - {image_id: 0, user_id: 0, interaction_type: 'faved', value: null}, - {image_id: 0, user_id: 0, interaction_type: 'voted', value: 'up'}, - {image_id: 1, user_id: 0, interaction_type: 'voted', value: 'down'}, - {image_id: 2, user_id: 0, interaction_type: 'hidden', value: null}, + { image_id: 0, user_id: 0, interaction_type: 'faved', value: null }, + { image_id: 0, user_id: 0, interaction_type: 'voted', value: 'up' }, + { image_id: 1, user_id: 0, interaction_type: 'voted', value: 'down' }, + { image_id: 2, user_id: 0, interaction_type: 'hidden', value: null }, ]; /* eslint-enable camelcase */ }); diff --git a/assets/js/query/date.ts b/assets/js/query/date.ts index b2dd4033..ab7f9955 100644 --- a/assets/js/query/date.ts +++ b/assets/js/query/date.ts @@ -44,7 +44,7 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi day: 86400000, week: 604800000, month: 2592000000, - year: 31536000000 + year: 31536000000, }; const amount = parseInt(match[1], 10); @@ -57,15 +57,22 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi return makeMatcher(bottomDate, topDate, qual); } +const parseRes: RegExp[] = [ + // year + /^(\d{4})/, + // month + /^-(\d{2})/, + // day + /^-(\d{2})/, + // hour + /^(?:\s+|T|t)(\d{2})/, + // minute + /^:(\d{2})/, + // second + /^:(\d{2})/, +]; + function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher { - const parseRes: RegExp[] = [ - /^(\d{4})/, - /^-(\d{2})/, - /^-(\d{2})/, - /^(?:\s+|T|t)(\d{2})/, - /^:(\d{2})/, - /^:(\d{2})/ - ]; const timeZoneOffset: TimeZoneOffset = [0, 0]; const timeData: AbsoluteDate = [0, 0, 1, 0, 0, 0]; @@ -81,8 +88,7 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi timeZoneOffset[1] *= -1; } localDateVal = localDateVal.substring(0, localDateVal.length - 6); - } - else { + } else { localDateVal = localDateVal.replace(/[Zz]$/, ''); } @@ -97,16 +103,14 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi if (matchIndex === 1) { // Months are offset by 1. timeData[matchIndex] = parseInt(componentMatch[1], 10) - 1; - } - else { + } else { // All other components are not offset. timeData[matchIndex] = parseInt(componentMatch[1], 10); } // Truncate string. localDateVal = localDateVal.substring(componentMatch[0].length); - } - else { + } else { throw new ParseError(`Cannot parse date string: ${origDateVal}`); } } diff --git a/assets/js/query/fields.ts b/assets/js/query/fields.ts index 0c1f82e0..0b666f7c 100644 --- a/assets/js/query/fields.ts +++ b/assets/js/query/fields.ts @@ -2,16 +2,23 @@ import { FieldName } from './types'; type AttributeName = string; -export const numberFields: FieldName[] = - ['id', 'width', 'height', 'aspect_ratio', - 'comment_count', 'score', 'upvotes', 'downvotes', - 'faves', 'tag_count', 'score']; +export const numberFields: FieldName[] = [ + 'id', + 'width', + 'height', + 'aspect_ratio', + 'comment_count', + 'score', + 'upvotes', + 'downvotes', + 'faves', + 'tag_count', + 'score', +]; export const dateFields: FieldName[] = ['created_at']; -export const literalFields = - ['tags', 'orig_sha512_hash', 'sha512_hash', - 'uploader', 'source_url', 'description']; +export const literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash', 'uploader', 'source_url', 'description']; export const termSpaceToImageField: Record = { tags: 'data-image-tag-aliases', @@ -32,7 +39,7 @@ export const termSpaceToImageField: Record = { faves: 'data-faves', sha512_hash: 'data-sha512', orig_sha512_hash: 'data-orig-sha512', - created_at: 'data-created-at' + created_at: 'data-created-at', /* eslint-enable camelcase */ }; diff --git a/assets/js/query/lex.ts b/assets/js/query/lex.ts index 2c950bd1..e98d8840 100644 --- a/assets/js/query/lex.ts +++ b/assets/js/query/lex.ts @@ -17,7 +17,7 @@ const tokenList: Token[] = [ ['not_op', /^\s*[!-]\s*/], ['space', /^\s+/], ['word', /^(?:\\[\s,()^~]|[^\s,()^~])+/], - ['word', /^(?:\\[\s,()]|[^\s,()])+/] + ['word', /^(?:\\[\s,()]|[^\s,()])+/], ]; export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher; @@ -26,14 +26,14 @@ export type Range = [number, number]; export type TermContext = [Range, string]; export interface LexResult { - tokenList: TokenList, - termContexts: TermContext[], - error: ParseError | null + tokenList: TokenList; + termContexts: TermContext[]; + error: ParseError | null; } export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult { - const opQueue: string[] = [], - groupNegate: boolean[] = []; + const opQueue: string[] = []; + const groupNegate: boolean[] = []; let searchTerm: string | null = null; let boostFuzzStr = ''; @@ -49,7 +49,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR const ret: LexResult = { tokenList: [], termContexts: [], - error: null + error: null, }; const beginTerm = (token: string) => { @@ -85,8 +85,10 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR } const token = match[0]; + const tokenIsBinaryOp = ['and_op', 'or_op'].indexOf(tokenName) !== -1; + const tokenIsGroupStart = tokenName === 'rparen' && lparenCtr === 0; - if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) { + if (searchTerm !== null && (tokenIsBinaryOp || tokenIsGroupStart)) { endTerm(); } @@ -107,8 +109,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR if (searchTerm) { // We're already inside a search term, so it does not apply, obv. searchTerm += token; - } - else { + } else { negate = !negate; } break; @@ -118,8 +119,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR // instead, consider it as part of the search term, as a user convenience. searchTerm += token; lparenCtr += 1; - } - else { + } else { opQueue.unshift('lparen'); groupNegate.push(negate); negate = false; @@ -129,8 +129,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR if (lparenCtr > 0) { searchTerm = assertNotNull(searchTerm) + token; lparenCtr -= 1; - } - else { + } else { while (opQueue.length > 0) { const op = assertNotUndefined(opQueue.shift()); if (op === 'lparen') { @@ -149,8 +148,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR // to a temporary string in case this is actually inside the term. fuzz = parseFloat(token.substring(1)); boostFuzzStr += token; - } - else { + } else { beginTerm(token); } break; @@ -158,16 +156,14 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR if (searchTerm) { boost = parseFloat(token.substring(1)); boostFuzzStr += token; - } - else { + } else { beginTerm(token); } break; case 'quoted_lit': if (searchTerm) { searchTerm += token; - } - else { + } else { beginTerm(token); } break; @@ -180,8 +176,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR boostFuzzStr = ''; } searchTerm += token; - } - else { + } else { beginTerm(token); } break; diff --git a/assets/js/query/literal.ts b/assets/js/query/literal.ts index 76bfd54c..0c057d39 100644 --- a/assets/js/query/literal.ts +++ b/assets/js/query/literal.ts @@ -22,13 +22,15 @@ function makeWildcardMatcher(term: string): FieldMatcher { // Transforms wildcard match into regular expression. // A custom NFA with caching may be more sophisticated but not // likely to be faster. - const wildcard = new RegExp( - `^${term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1') - .replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*') - .replace(/^(?:\\\\)*\*/g, '.*') - .replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?') - .replace(/^(?:\\\\)*\?/g, '.?')}$`, 'i' - ); + + const regexpForm = term + .replace(/([.+^$[\]\\(){}|-])/g, '\\$1') + .replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*') + .replace(/^(?:\\\\)*\*/g, '.*') + .replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?') + .replace(/^(?:\\\\)*\?/g, '.?'); + + const wildcard = new RegExp(`^${regexpForm}$`, 'i'); return (v, name) => { const values = extractValues(v, name); @@ -69,10 +71,9 @@ function fuzzyMatch(term: string, targetStr: string, fuzz: number): boolean { // Insertion. v2[j] + 1, // Substitution or No Change. - v1[j] + cost + v1[j] + cost, ); - if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] && - targetStrLower[i - 1] === targetStrLower[j]) { + if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] && targetStrLower[i - 1] === targetStrLower[j]) { v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost); } } diff --git a/assets/js/query/matcher.ts b/assets/js/query/matcher.ts index d8e67df2..c16b6010 100644 --- a/assets/js/query/matcher.ts +++ b/assets/js/query/matcher.ts @@ -6,10 +6,10 @@ import { makeUserMatcher } from './user'; import { FieldMatcher, RangeEqualQualifier } from './types'; export interface MatcherFactory { - makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher, - makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher, - makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher, - makeUserMatcher: (term: string) => FieldMatcher + makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher; + makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher; + makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher; + makeUserMatcher: (term: string) => FieldMatcher; } export const defaultMatcher: MatcherFactory = { diff --git a/assets/js/query/parse.ts b/assets/js/query/parse.ts index fea7659b..7f725c6c 100644 --- a/assets/js/query/parse.ts +++ b/assets/js/query/parse.ts @@ -23,19 +23,16 @@ export function parseTokens(lexicalArray: TokenList): AstMatcher { if (token === 'and_op') { intermediate = matchAll(op1, op2); - } - else { + } else { intermediate = matchAny(op1, op2); } - } - else { + } else { intermediate = token; } if (lexicalArray[i + 1] === 'not_op') { operandStack.push(matchNot(intermediate)); - } - else { + } else { operandStack.push(intermediate); } } diff --git a/assets/js/query/term.ts b/assets/js/query/term.ts index 22b161eb..1708b810 100644 --- a/assets/js/query/term.ts +++ b/assets/js/query/term.ts @@ -67,11 +67,9 @@ function makeTermMatcher(term: string, fuzz: number, factory: MatcherFactory): [ } return [fieldName, factory.makeNumberMatcher(parseFloat(termCandidate), fuzz, rangeType)]; - } - else if (literalFields.indexOf(candidateTermSpace) !== -1) { + } else if (literalFields.indexOf(candidateTermSpace) !== -1) { return [candidateTermSpace, factory.makeLiteralMatcher(termCandidate, fuzz, wildcardable)]; - } - else if (candidateTermSpace === 'my') { + } else if (candidateTermSpace === 'my') { return [candidateTermSpace, factory.makeUserMatcher(termCandidate)]; } } diff --git a/assets/js/query/user.ts b/assets/js/query/user.ts index 3f383425..1410813f 100644 --- a/assets/js/query/user.ts +++ b/assets/js/query/user.ts @@ -1,8 +1,15 @@ import { Interaction, InteractionType, InteractionValue } from '../../types/booru-object'; import { FieldMatcher } from './types'; -function interactionMatch(imageId: number, type: InteractionType, value: InteractionValue, interactions: Interaction[]): boolean { - return interactions.some(v => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value)); +function interactionMatch( + imageId: number, + type: InteractionType, + value: InteractionValue, + interactions: Interaction[], +): boolean { + return interactions.some( + v => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value), + ); } export function makeUserMatcher(term: string): FieldMatcher { diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js index 055c4408..4457784a 100644 --- a/assets/js/quick-tag.js +++ b/assets/js/quick-tag.js @@ -9,56 +9,54 @@ import { fetchJson, handleError } from './utils/requests'; const imageQueueStorage = 'quickTagQueue'; const currentTagStorage = 'quickTagName'; -function currentQueue() { return store.get(imageQueueStorage) || []; } +function currentQueue() { + return store.get(imageQueueStorage) || []; +} -function currentTags() { return store.get(currentTagStorage) || ''; } +function currentTags() { + return store.get(currentTagStorage) || ''; +} -function getTagButton() { return $('.js-quick-tag'); } +function getTagButton() { + return $('.js-quick-tag'); +} -function setTagButton(text) { $('.js-quick-tag--submit span').textContent = text; } +function setTagButton(text) { + $('.js-quick-tag--submit span').textContent = text; +} function toggleActiveState() { - - toggleEl($('.js-quick-tag'), - $('.js-quick-tag--abort'), - $('.js-quick-tag--all'), - $('.js-quick-tag--submit')); + toggleEl($('.js-quick-tag'), $('.js-quick-tag--abort'), $('.js-quick-tag--all'), $('.js-quick-tag--submit')); setTagButton(`Submit (${currentTags()})`); $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); - currentQueue().forEach(id => $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected'))); - + currentQueue().forEach(id => + $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')), + ); } function activate() { - store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); if (currentTags()) toggleActiveState(); - } function reset() { - store.remove(currentTagStorage); store.remove(imageQueueStorage); toggleActiveState(); - } function promptReset() { - if (window.confirm('Are you sure you want to abort batch tagging?')) { reset(); } - } function submit() { - setTagButton(`Wait... (${currentTags()})`); fetchJson('PUT', '/admin/batch/tags', { @@ -68,30 +66,26 @@ function submit() { .then(handleError) .then(r => r.json()) .then(data => { - if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); reset(); - }); - } function modifyImageQueue(mediaBox) { - if (currentTags()) { - const imageId = mediaBox.dataset.imageId, - queue = currentQueue(), - isSelected = queue.includes(imageId); + const imageId = mediaBox.dataset.imageId; + const queue = currentQueue(); + const isSelected = queue.includes(imageId); - isSelected ? queue.splice(queue.indexOf(imageId), 1) - : queue.push(imageId); + isSelected ? queue.splice(queue.indexOf(imageId), 1) : queue.push(imageId); - $$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => el.classList.toggle('media-box__header--selected')); + $$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => + el.classList.toggle('media-box__header--selected'), + ); store.set(imageQueueStorage, queue); } - } function toggleAllImages() { @@ -99,7 +93,6 @@ function toggleAllImages() { } function clickHandler(event) { - const targets = { '.js-quick-tag': activate, '.js-quick-tag--abort': promptReset, @@ -114,14 +107,11 @@ function clickHandler(event) { currentTags() && event.preventDefault(); } } - } function setupQuickTag() { - if (getTagButton() && currentTags()) toggleActiveState(); if (getTagButton()) onLeftClick(clickHandler); - } export { setupQuickTag }; diff --git a/assets/js/resizablemedia.js b/assets/js/resizablemedia.js index 36c1bee5..0c7528a6 100644 --- a/assets/js/resizablemedia.js +++ b/assets/js/resizablemedia.js @@ -3,14 +3,17 @@ let mediaContainers; /* Hardcoded dimensions of thumb boxes; at mediaLargeMinSize, large box becomes a small one (font size gets diminished). * At minimum width, the large box still has four digit fave/score numbers and five digit comment number fitting in a single row * (small box may lose the number of comments in a hidden overflow) */ -const mediaLargeMaxSize = 250, mediaLargeMinSize = 190, mediaSmallMaxSize = 156, mediaSmallMinSize = 140; +const mediaLargeMaxSize = 250, + mediaLargeMinSize = 190, + mediaSmallMaxSize = 156, + mediaSmallMinSize = 140; /* Margin between thumbs (6) + borders (2) + 1 extra px to correct rounding errors */ const mediaBoxOffset = 9; export function processResizableMedia() { [].slice.call(mediaContainers).forEach(container => { const containerHasLargeBoxes = container.querySelector('.media-box__content--large') !== null, - containerWidth = container.offsetWidth - 14; /* subtract container padding */ + containerWidth = container.offsetWidth - 14; /* subtract container padding */ /* If at least three large boxes fit in a single row, we do not downsize them to small ones. * This ensures that desktop users get less boxes in a row, but with bigger images inside. */ @@ -21,9 +24,8 @@ export function processResizableMedia() { /* Larger boxes are preferred to more items in a row */ setMediaSize(container, containerWidth, mediaLargeMinSize, mediaLargeMaxSize); } - } - /* Mobile users, on the other hand, should get as many boxes in a row as possible */ - else { + } else { + /* Mobile users, on the other hand, should get as many boxes in a row as possible */ setMediaSize(container, containerWidth, mediaSmallMinSize, mediaSmallMaxSize); } }); @@ -43,8 +45,7 @@ function applyMediaSize(container, size) { * To prevent that, we add a class that diminishes its padding and font size. */ if (size < mediaLargeMinSize) { header.classList.add('media-box__header--small'); - } - else { + } else { header.classList.remove('media-box__header--small'); } }); @@ -52,9 +53,9 @@ function applyMediaSize(container, size) { function setMediaSize(container, containerWidth, minMediaSize, maxMediaSize) { const maxThumbsFitting = Math.floor(containerWidth / (minMediaSize + mediaBoxOffset)), - minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)), - fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2), - thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize); + minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)), + fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2), + thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize); applyMediaSize(container, thumbSize); } diff --git a/assets/js/search.js b/assets/js/search.js index 864ea231..50733fd9 100644 --- a/assets/js/search.js +++ b/assets/js/search.js @@ -6,8 +6,7 @@ function showHelp(subject, type) { if (helpBox.getAttribute('data-search-help') === type) { $('.js-search-help-subject', helpBox).textContent = subject; helpBox.classList.remove('hidden'); - } - else { + } else { helpBox.classList.add('hidden'); } }); @@ -16,7 +15,8 @@ function showHelp(subject, type) { function prependToLast(field, value) { const separatorIndex = field.value.lastIndexOf(','); const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1; - field.value = field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy); + field.value = + field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy); } function selectLast(field, characterCount) { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 9ff6b17f..c60a2ee0 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -7,7 +7,6 @@ import { $, $$ } from './utils/dom'; import store from './utils/store'; export function setupSettings() { - if (!$('#js-setting-table')) return; const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]'); diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts index 48551a3b..682863ef 100644 --- a/assets/js/shortcuts.ts +++ b/assets/js/shortcuts.ts @@ -37,31 +37,39 @@ function click(selector: string) { } function isOK(event: KeyboardEvent): boolean { - return !event.altKey && !event.ctrlKey && !event.metaKey && - document.activeElement !== null && - document.activeElement.tagName !== 'INPUT' && - document.activeElement.tagName !== 'TEXTAREA'; + return ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + document.activeElement !== null && + document.activeElement.tagName !== 'INPUT' && + document.activeElement.tagName !== 'TEXTAREA' + ); } +/* eslint-disable prettier/prettier */ + const keyCodes: ShortcutKeyMap = { - 'j'() { click('.js-prev'); }, // J - go to previous image - 'i'() { click('.js-up'); }, // I - go to index page - 'k'() { click('.js-next'); }, // K - go to next image - 'r'() { click('.js-rand'); }, // R - go to random image - 's'() { click('.js-source-link'); }, // S - go to image source - 'l'() { click('.js-tag-sauce-toggle'); }, // L - edit tags - 'o'() { openFullView(); }, // O - open original - 'v'() { openFullViewNewTab(); }, // V - open original in a new tab - 'f'() { // F - favourite image - click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` - : '.block__header a.interaction--fave'); + j() { click('.js-prev'); }, // J - go to previous image + i() { click('.js-up'); }, // I - go to index page + k() { click('.js-next'); }, // K - go to next image + r() { click('.js-rand'); }, // R - go to random image + s() { click('.js-source-link'); }, // S - go to image source + l() { click('.js-tag-sauce-toggle'); }, // L - edit tags + o() { openFullView(); }, // O - open original + v() { openFullViewNewTab(); }, // V - open original in a new tab + f() { + // F - favourite image + click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` : '.block__header a.interaction--fave'); }, - 'u'() { // U - upvote image - click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` - : '.block__header a.interaction--upvote'); + u() { + // U - upvote image + click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` : '.block__header a.interaction--upvote'); }, }; +/* eslint-enable prettier/prettier */ + export function listenForKeys() { document.addEventListener('keydown', (event: KeyboardEvent) => { if (isOK(event) && keyCodes[event.key]) { diff --git a/assets/js/tags.ts b/assets/js/tags.ts index c3547f27..8a0568fc 100644 --- a/assets/js/tags.ts +++ b/assets/js/tags.ts @@ -19,10 +19,14 @@ function removeTag(tagId: number, list: number[]) { function createTagDropdown(tag: HTMLSpanElement) { const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru; - const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$('.tag__dropdown__link', tag); - const [ unwatched, watched, spoilered, hidden ] = $$('.tag__state', tag); + const [unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter] = $$( + '.tag__dropdown__link', + tag, + ); + const [unwatched, watched, spoilered, hidden] = $$('.tag__state', tag); const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10); + /* eslint-disable prettier/prettier */ const actions: TagDropdownActionList = { unwatch() { hideEl(unwatch, watched); showEl(watch, unwatched); removeTag(tagId, watchedTagList); }, watch() { hideEl(watch, unwatched); showEl(unwatch, watched); addTag(tagId, watchedTagList); }, @@ -33,28 +37,28 @@ function createTagDropdown(tag: HTMLSpanElement) { unhide() { hideEl(unhide, hidden); showEl(hide); removeTag(tagId, hiddenTagList); }, hide() { hideEl(hide); showEl(unhide, hidden); addTag(tagId, hiddenTagList); }, }; + /* eslint-enable prettier/prettier */ - const tagIsWatched = watchedTagList.includes(tagId); + const tagIsWatched = watchedTagList.includes(tagId); const tagIsSpoilered = spoileredTagList.includes(tagId); - const tagIsHidden = hiddenTagList.includes(tagId); + const tagIsHidden = hiddenTagList.includes(tagId); - const watchedLink = tagIsWatched ? unwatch : watch; - const spoilerLink = tagIsSpoilered ? unspoiler : spoiler; - const hiddenLink = tagIsHidden ? unhide : hide; + const watchedLink = tagIsWatched ? unwatch : watch; + const spoilerLink = tagIsSpoilered ? unspoiler : spoiler; + const hiddenLink = tagIsHidden ? unhide : hide; // State symbols (-, S, H, +) - if (tagIsWatched) showEl(watched); - if (tagIsSpoilered) showEl(spoilered); - if (tagIsHidden) showEl(hidden); - if (!tagIsWatched) showEl(unwatched); + if (tagIsWatched) showEl(watched); + if (tagIsSpoilered) showEl(spoilered); + if (tagIsHidden) showEl(hidden); + if (!tagIsWatched) showEl(unwatched); // Dropdown links - if (userIsSignedIn) showEl(watchedLink); + if (userIsSignedIn) showEl(watchedLink); if (userCanEditFilter) showEl(spoilerLink); if (userCanEditFilter) showEl(hiddenLink); - if (!userIsSignedIn) showEl(signIn); - if (userIsSignedIn && - !userCanEditFilter) showEl(filter); + if (!userIsSignedIn) showEl(signIn); + if (userIsSignedIn && !userCanEditFilter) showEl(filter); tag.addEventListener('fetchcomplete', event => { const act = assertNotUndefined(event.target.dataset.tagAction); diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.js index 1f6963ac..bc05b3d7 100644 --- a/assets/js/tagsinput.js +++ b/assets/js/tagsinput.js @@ -5,7 +5,7 @@ import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom'; function setupTagsInput(tagBlock) { - const [ textarea, container ] = $$('.js-taginput', tagBlock); + const [textarea, container] = $$('.js-taginput', tagBlock); const setup = $('.js-tag-block ~ button', tagBlock.parentNode); const inputField = $('input', container); @@ -42,7 +42,6 @@ function setupTagsInput(tagBlock) { importTags(); } - function handleAutocomplete(event) { insertTag(event.detail.value); inputField.focus(); @@ -85,7 +84,6 @@ function setupTagsInput(tagBlock) { inputField.value.split(',').forEach(t => insertTag(t)); inputField.value = ''; } - } function handleCtrlEnter(event) { @@ -138,8 +136,10 @@ function setupTagsInput(tagBlock) { function fancyEditorRequested(tagBlock) { // Check whether the user made the fancy editor the default for each type of tag block. - return window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload') || - window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit'); + return ( + (window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) || + (window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit')) + ); } function setupTagListener() { diff --git a/assets/js/tagsmisc.ts b/assets/js/tagsmisc.ts index 09366386..17fdf6d3 100644 --- a/assets/js/tagsmisc.ts +++ b/assets/js/tagsmisc.ts @@ -30,7 +30,7 @@ function tagInputButtons(event: MouseEvent) { }, }; - for (const [ name, action ] of Object.entries(actions)) { + for (const [name, action] of Object.entries(actions)) { if (target && target.matches(`#tagsinput-${name}`)) { action(assertNotNull($('#image_tag_input'))); } diff --git a/assets/js/timeago.ts b/assets/js/timeago.ts index 12f39bd9..a53f2f58 100644 --- a/assets/js/timeago.ts +++ b/assets/js/timeago.ts @@ -35,25 +35,25 @@ function setTimeAgo(el: HTMLTimeElement) { const date = new Date(datetime); const distMillis = distance(date); - const seconds = Math.abs(distMillis) / 1000, - minutes = seconds / 60, - hours = minutes / 60, - days = hours / 24, - months = days / 30, - years = days / 365; + const seconds = Math.abs(distMillis) / 1000; + const minutes = seconds / 60; + const hours = minutes / 60; + const days = hours / 24; + const months = days / 30; + const years = days / 365; const words = - seconds < 45 && substitute('seconds', seconds) || - seconds < 90 && substitute('minute', 1) || - minutes < 45 && substitute('minutes', minutes) || - minutes < 90 && substitute('hour', 1) || - hours < 24 && substitute('hours', hours) || - hours < 42 && substitute('day', 1) || - days < 30 && substitute('days', days) || - days < 45 && substitute('month', 1) || - days < 365 && substitute('months', months) || - years < 1.5 && substitute('year', 1) || - substitute('years', years); + (seconds < 45 && substitute('seconds', seconds)) || + (seconds < 90 && substitute('minute', 1)) || + (minutes < 45 && substitute('minutes', minutes)) || + (minutes < 90 && substitute('hour', 1)) || + (hours < 24 && substitute('hours', hours)) || + (hours < 42 && substitute('day', 1)) || + (days < 30 && substitute('days', days)) || + (days < 45 && substitute('month', 1)) || + (days < 365 && substitute('months', months)) || + (years < 1.5 && substitute('year', 1)) || + substitute('years', years); if (!el.getAttribute('title')) { el.setAttribute('title', assertNotNull(el.textContent)); diff --git a/assets/js/ujs.ts b/assets/js/ujs.ts index 413bc6cb..b2905806 100644 --- a/assets/js/ujs.ts +++ b/assets/js/ujs.ts @@ -4,7 +4,7 @@ import { fire, delegate, leftClick } from './utils/events'; const headers = () => ({ 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'XMLHttpRequest' + 'x-requested-with': 'XMLHttpRequest', }); function confirm(event: Event, target: HTMLElement) { @@ -25,8 +25,7 @@ function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | H if (label) { target.dataset.enableWith = assertNotNull(label.nodeValue); label.nodeValue = ` ${target.dataset.disableWith}`; - } - else { + } else { target.dataset.enableWith = target.innerHTML; target.innerHTML = assertNotUndefined(target.dataset.disableWith); } @@ -39,8 +38,8 @@ function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | H function linkMethod(event: Event, target: HTMLAnchorElement) { event.preventDefault(); - const form = makeEl('form', { action: target.href, method: 'POST' }); - const csrf = makeEl('input', { type: 'hidden', name: '_csrf_token', value: window.booru.csrfToken }); + const form = makeEl('form', { action: target.href, method: 'POST' }); + const csrf = makeEl('input', { type: 'hidden', name: '_csrf_token', value: window.booru.csrfToken }); const method = makeEl('input', { type: 'hidden', name: '_method', value: target.dataset.method }); document.body.appendChild(form); @@ -57,7 +56,7 @@ function formRemote(event: Event, target: HTMLFormElement) { credentials: 'same-origin', method: (target.dataset.method || target.method).toUpperCase(), headers: headers(), - body: new FormData(target) + body: new FormData(target), }).then(response => { fire(target, 'fetchcomplete', response); if (response && response.status === 300) { @@ -71,8 +70,7 @@ function formReset(_event: Event | null, target: HTMLElement) { const label = findFirstTextNode(input); if (label) { label.nodeValue = ` ${input.dataset.enableWith}`; - } - else { + } else { input.innerHTML = assertNotUndefined(input.dataset.enableWith); } delete input.dataset.enableWith; @@ -86,10 +84,8 @@ function linkRemote(event: Event, target: HTMLAnchorElement) { fetch(target.href, { credentials: 'same-origin', method: (target.dataset.method || 'get').toUpperCase(), - headers: headers() - }).then(response => - fire(target, 'fetchcomplete', response) - ); + headers: headers(), + }).then(response => fire(target, 'fetchcomplete', response)); } delegate(document, 'click', { @@ -100,11 +96,11 @@ delegate(document, 'click', { }); delegate(document, 'submit', { - 'form[data-remote]': formRemote + 'form[data-remote]': formRemote, }); delegate(document, 'reset', { - form: formReset + form: formReset, }); window.addEventListener('pageshow', () => { diff --git a/assets/js/upload.js b/assets/js/upload.js index 62f749fb..16d33959 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -68,17 +68,25 @@ function setupImageUpload() { showEl(scraperError); enableFetch(); } - function hideError() { hideEl(scraperError); } - function disableFetch() { fetchButton.setAttribute('disabled', ''); } - function enableFetch() { fetchButton.removeAttribute('disabled'); } + function hideError() { + hideEl(scraperError); + } + function disableFetch() { + fetchButton.setAttribute('disabled', ''); + } + function enableFetch() { + fetchButton.removeAttribute('disabled'); + } const reader = new FileReader(); reader.addEventListener('load', event => { - showImages([{ - camo_url: event.target.result, - type: fileField.files[0].type - }]); + showImages([ + { + camo_url: event.target.result, + type: fileField.files[0].type, + }, + ]); // Clear any currently cached data, because the file field // has higher priority than the scraper: @@ -88,7 +96,9 @@ function setupImageUpload() { }); // Watch for files added to the form - fileField.addEventListener('change', () => { fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]); }); + fileField.addEventListener('change', () => { + fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]); + }); // Watch for [Fetch] clicks fetchButton.addEventListener('click', () => { @@ -96,37 +106,39 @@ function setupImageUpload() { disableFetch(); - scrapeUrl(remoteUrl.value).then(data => { - if (data === null) { - scraperError.innerText = 'No image found at that address.'; - showError(); - return; - } - else if (data.errors && data.errors.length > 0) { - scraperError.innerText = data.errors.join(' '); - showError(); - return; - } + scrapeUrl(remoteUrl.value) + .then(data => { + if (data === null) { + scraperError.innerText = 'No image found at that address.'; + showError(); + return; + } else if (data.errors && data.errors.length > 0) { + scraperError.innerText = data.errors.join(' '); + showError(); + return; + } - hideError(); + hideError(); - // Set source - if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || ''; - // Set description - if (descrEl) descrEl.value = descrEl.value || data.description || ''; - // Add author - if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`); - // Clear selected file, if any - fileField.value = ''; - showImages(data.images); + // Set source + if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || ''; + // Set description + if (descrEl) descrEl.value = descrEl.value || data.description || ''; + // Add author + if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`); + // Clear selected file, if any + fileField.value = ''; + showImages(data.images); - enableFetch(); - }).catch(showError); + enableFetch(); + }) + .catch(showError); }); // Fetch on "enter" in url field remoteUrl.addEventListener('keydown', event => { - if (event.keyCode === 13) { // Hit enter + if (event.keyCode === 13) { + // Hit enter fetchButton.click(); } }); @@ -135,8 +147,7 @@ function setupImageUpload() { function setFetchEnabled() { if (remoteUrl.value.length > 0) { enableFetch(); - } - else { + } else { disableFetch(); } } diff --git a/assets/js/utils/__tests__/array.spec.ts b/assets/js/utils/__tests__/array.spec.ts index 4b1a9501..89e8f76d 100644 --- a/assets/js/utils/__tests__/array.spec.ts +++ b/assets/js/utils/__tests__/array.spec.ts @@ -84,15 +84,17 @@ describe('Array Utilities', () => { // Mixed parameters const mockObject = { value: Math.random() }; - expect(arraysEqual( - ['', null, false, uniqueValue, mockObject, Infinity, undefined], - ['', null, false, uniqueValue, mockObject, Infinity, undefined] - )).toBe(true); + expect( + arraysEqual( + ['', null, false, uniqueValue, mockObject, Infinity, undefined], + ['', null, false, uniqueValue, mockObject, Infinity, undefined], + ), + ).toBe(true); }); }); describe('negative cases', () => { - it('should NOT return true for matching only up to the first array\'s length', () => { + it("should NOT return true for matching only up to the first array's length", () => { // Numbers expect(arraysEqual([0], [0, 1])).toBe(false); expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false); @@ -108,26 +110,15 @@ describe('Array Utilities', () => { // Mixed parameters const mockObject = { value: Math.random() }; - expect(arraysEqual( - [''], - ['', null, false, mockObject, Infinity, undefined] - )).toBe(false); - expect(arraysEqual( - ['', null], - ['', null, false, mockObject, Infinity, undefined] - )).toBe(false); - expect(arraysEqual( - ['', null, false], - ['', null, false, mockObject, Infinity, undefined] - )).toBe(false); - expect(arraysEqual( - ['', null, false, mockObject], - ['', null, false, mockObject, Infinity, undefined] - )).toBe(false); - expect(arraysEqual( - ['', null, false, mockObject, Infinity], - ['', null, false, mockObject, Infinity, undefined] - )).toBe(false); + expect(arraysEqual([''], ['', null, false, mockObject, Infinity, undefined])).toBe(false); + expect(arraysEqual(['', null], ['', null, false, mockObject, Infinity, undefined])).toBe(false); + expect(arraysEqual(['', null, false], ['', null, false, mockObject, Infinity, undefined])).toBe(false); + expect(arraysEqual(['', null, false, mockObject], ['', null, false, mockObject, Infinity, undefined])).toBe( + false, + ); + expect( + arraysEqual(['', null, false, mockObject, Infinity], ['', null, false, mockObject, Infinity, undefined]), + ).toBe(false); }); it('should return false for arrays of different length', () => { @@ -151,7 +142,7 @@ describe('Array Utilities', () => { expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false); }); - it('should return false if items up to the first array\'s length differ', () => { + it("should return false if items up to the first array's length differ", () => { // Numbers expect(arraysEqual([0], [1])).toBe(false); expect(arraysEqual([0, 1], [1, 2])).toBe(false); @@ -168,22 +159,12 @@ describe('Array Utilities', () => { expect(arraysEqual([mockObject1], [mockObject2])).toBe(false); // Mixed parameters - expect(arraysEqual( - ['a'], - ['b', null, false, mockObject2, Infinity] - )).toBe(false); - expect(arraysEqual( - ['a', null, true], - ['b', null, false, mockObject2, Infinity] - )).toBe(false); - expect(arraysEqual( - ['a', null, true, mockObject1], - ['b', null, false, mockObject2, Infinity] - )).toBe(false); - expect(arraysEqual( - ['a', null, true, mockObject1, -Infinity], - ['b', null, false, mockObject2, Infinity] - )).toBe(false); + expect(arraysEqual(['a'], ['b', null, false, mockObject2, Infinity])).toBe(false); + expect(arraysEqual(['a', null, true], ['b', null, false, mockObject2, Infinity])).toBe(false); + expect(arraysEqual(['a', null, true, mockObject1], ['b', null, false, mockObject2, Infinity])).toBe(false); + expect(arraysEqual(['a', null, true, mockObject1, -Infinity], ['b', null, false, mockObject2, Infinity])).toBe( + false, + ); }); }); }); diff --git a/assets/js/utils/__tests__/dom.spec.ts b/assets/js/utils/__tests__/dom.spec.ts index a1bb03eb..dfd0faa2 100644 --- a/assets/js/utils/__tests__/dom.spec.ts +++ b/assets/js/utils/__tests__/dom.spec.ts @@ -87,11 +87,7 @@ describe('DOM Utilities', () => { }); it(`should remove the ${hiddenClass} class from all provided elements`, () => { - const mockElements = [ - createHiddenElement('div'), - createHiddenElement('a'), - createHiddenElement('strong'), - ]; + const mockElements = [createHiddenElement('div'), createHiddenElement('a'), createHiddenElement('strong')]; showEl(mockElements); expect(mockElements[0]).not.toHaveClass(hiddenClass); expect(mockElements[1]).not.toHaveClass(hiddenClass); @@ -99,14 +95,8 @@ describe('DOM Utilities', () => { }); it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => { - const mockElements1 = [ - createHiddenElement('div'), - createHiddenElement('a'), - ]; - const mockElements2 = [ - createHiddenElement('strong'), - createHiddenElement('em'), - ]; + const mockElements1 = [createHiddenElement('div'), createHiddenElement('a')]; + const mockElements2 = [createHiddenElement('strong'), createHiddenElement('em')]; showEl(mockElements1, mockElements2); expect(mockElements1[0]).not.toHaveClass(hiddenClass); expect(mockElements1[1]).not.toHaveClass(hiddenClass); @@ -135,14 +125,8 @@ describe('DOM Utilities', () => { }); it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => { - const mockElements1 = [ - document.createElement('div'), - document.createElement('a'), - ]; - const mockElements2 = [ - document.createElement('strong'), - document.createElement('em'), - ]; + const mockElements1 = [document.createElement('div'), document.createElement('a')]; + const mockElements2 = [document.createElement('strong'), document.createElement('em')]; hideEl(mockElements1, mockElements2); expect(mockElements1[0]).toHaveClass(hiddenClass); expect(mockElements1[1]).toHaveClass(hiddenClass); @@ -159,24 +143,15 @@ describe('DOM Utilities', () => { }); it('should set the disabled attribute to true on all provided elements', () => { - const mockElements = [ - document.createElement('input'), - document.createElement('button'), - ]; + const mockElements = [document.createElement('input'), document.createElement('button')]; disableEl(mockElements); expect(mockElements[0]).toBeDisabled(); expect(mockElements[1]).toBeDisabled(); }); it('should set the disabled attribute to true on elements provided in multiple arrays', () => { - const mockElements1 = [ - document.createElement('input'), - document.createElement('button'), - ]; - const mockElements2 = [ - document.createElement('textarea'), - document.createElement('button'), - ]; + const mockElements1 = [document.createElement('input'), document.createElement('button')]; + const mockElements2 = [document.createElement('textarea'), document.createElement('button')]; disableEl(mockElements1, mockElements2); expect(mockElements1[0]).toBeDisabled(); expect(mockElements1[1]).toBeDisabled(); @@ -193,24 +168,15 @@ describe('DOM Utilities', () => { }); it('should set the disabled attribute to false on all provided elements', () => { - const mockElements = [ - document.createElement('input'), - document.createElement('button'), - ]; + const mockElements = [document.createElement('input'), document.createElement('button')]; enableEl(mockElements); expect(mockElements[0]).toBeEnabled(); expect(mockElements[1]).toBeEnabled(); }); it('should set the disabled attribute to false on elements provided in multiple arrays', () => { - const mockElements1 = [ - document.createElement('input'), - document.createElement('button'), - ]; - const mockElements2 = [ - document.createElement('textarea'), - document.createElement('button'), - ]; + const mockElements1 = [document.createElement('input'), document.createElement('button')]; + const mockElements2 = [document.createElement('textarea'), document.createElement('button')]; enableEl(mockElements1, mockElements2); expect(mockElements1[0]).toBeEnabled(); expect(mockElements1[1]).toBeEnabled(); @@ -245,14 +211,8 @@ describe('DOM Utilities', () => { }); it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => { - const mockElements1 = [ - createHiddenElement('div'), - document.createElement('a'), - ]; - const mockElements2 = [ - createHiddenElement('strong'), - document.createElement('em'), - ]; + const mockElements1 = [createHiddenElement('div'), document.createElement('a')]; + const mockElements2 = [createHiddenElement('strong'), document.createElement('em')]; toggleEl(mockElements1, mockElements2); expect(mockElements1[0]).not.toHaveClass(hiddenClass); expect(mockElements1[1]).toHaveClass(hiddenClass); @@ -430,8 +390,7 @@ describe('DOM Utilities', () => { try { whenReady(mockCallback); expect(mockCallback).toHaveBeenCalledTimes(1); - } - finally { + } finally { readyStateSpy.mockRestore(); } }); @@ -446,8 +405,7 @@ describe('DOM Utilities', () => { expect(addEventListenerSpy).toHaveBeenCalledTimes(1); expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback); expect(mockCallback).not.toHaveBeenCalled(); - } - finally { + } finally { readyStateSpy.mockRestore(); addEventListenerSpy.mockRestore(); } @@ -456,7 +414,9 @@ describe('DOM Utilities', () => { describe('escapeHtml', () => { it('should replace only the expected characters with their HTML entity equivalents', () => { - expect(escapeHtml('')).toBe('<script src="http://example.com/?a=1&b=2"></script>'); + expect(escapeHtml('')).toBe( + '<script src="http://example.com/?a=1&b=2"></script>', + ); }); }); diff --git a/assets/js/utils/__tests__/draggable.spec.ts b/assets/js/utils/__tests__/draggable.spec.ts index cda0598a..7f9357a0 100644 --- a/assets/js/utils/__tests__/draggable.spec.ts +++ b/assets/js/utils/__tests__/draggable.spec.ts @@ -14,7 +14,7 @@ describe('Draggable Utilities', () => { items: items as unknown as DataTransferItemList, setData(format: string, data: string) { items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) }); - } + }, } as unknown as DataTransfer; } Object.assign(mockEvent, { dataTransfer }); @@ -44,7 +44,6 @@ describe('Draggable Utilities', () => { mockDraggable = createDraggableElement(); mockDragContainer.appendChild(mockDraggable); - // Redirect all document event listeners to this element for easier cleanup documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => { mockDragContainer.addEventListener(...params); @@ -67,7 +66,7 @@ describe('Draggable Utilities', () => { expect(mockDraggable).toHaveClass(draggingClass); }); - it('should add dummy data to the dragstart event if it\'s empty', () => { + it("should add dummy data to the dragstart event if it's empty", () => { initDraggables(); const mockEvent = createDragEvent('dragstart'); @@ -87,7 +86,7 @@ describe('Draggable Utilities', () => { expect(stringValue).toEqual(''); }); - it('should keep data in the dragstart event if it\'s present', () => { + it("should keep data in the dragstart event if it's present", () => { initDraggables(); const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']); @@ -95,7 +94,9 @@ describe('Draggable Utilities', () => { type: mockTransferItemType, } as unknown as DataTransferItem; - const mockEvent = createDragEvent('dragstart', { dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList } } as DragEventInit); + const mockEvent = createDragEvent('dragstart', { + dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList }, + } as DragEventInit); expect(mockEvent.dataTransfer?.items).toHaveLength(1); fireEvent(mockDraggable, mockEvent); @@ -203,8 +204,7 @@ describe('Draggable Utilities', () => { expect(mockDropEvent.defaultPrevented).toBe(true); expect(mockSecondDraggable).not.toHaveClass(draggingClass); expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable); - } - finally { + } finally { boundingBoxSpy.mockRestore(); } }); @@ -232,8 +232,7 @@ describe('Draggable Utilities', () => { expect(mockDropEvent.defaultPrevented).toBe(true); expect(mockSecondDraggable).not.toHaveClass(draggingClass); expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable); - } - finally { + } finally { boundingBoxSpy.mockRestore(); } }); @@ -254,7 +253,7 @@ describe('Draggable Utilities', () => { }); describe('dragEnd', () => { - it('should remove dragging class from source and over class from target\'s descendants', () => { + it("should remove dragging class from source and over class from target's descendants", () => { initDraggables(); const mockStartEvent = createDragEvent('dragstart'); @@ -298,8 +297,7 @@ describe('Draggable Utilities', () => { fireEvent(mockDraggable, mockEvent); expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy(); - } - finally { + } finally { draggableClosestSpy.mockRestore(); } }); diff --git a/assets/js/utils/__tests__/image.spec.ts b/assets/js/utils/__tests__/image.spec.ts index f27a1cd1..ded1389b 100644 --- a/assets/js/utils/__tests__/image.spec.ts +++ b/assets/js/utils/__tests__/image.spec.ts @@ -92,7 +92,7 @@ describe('Image utils', () => { extension: string; videoClasses?: string[]; imgClasses?: string[]; - } + }; const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => { const mockElement = document.createElement('div'); @@ -131,18 +131,11 @@ describe('Image utils', () => { }; it('should hide the img element and show the video instead if no picture element is present', () => { - const { - mockElement, - mockImage, - playSpy, - mockVideo, - mockSize, - mockSizeUrls, - mockSpoilerOverlay, - } = createMockElements({ - extension: 'webm', - videoClasses: ['hidden'], - }); + const { mockElement, mockImage, playSpy, mockVideo, mockSize, mockSizeUrls, mockSpoilerOverlay } = + createMockElements({ + extension: 'webm', + videoClasses: ['hidden'], + }); const result = showThumb(mockElement); @@ -181,8 +174,7 @@ describe('Image utils', () => { const result = showThumb(mockElement); expect(result).toBe(false); expect(jsonParseSpy).not.toHaveBeenCalled(); - } - finally { + } finally { jsonParseSpy.mockRestore(); } }); @@ -226,13 +218,8 @@ describe('Image utils', () => { }); it('should show the correct thumbnail image for jpg extension', () => { - const { - mockElement, - mockSizeImage, - mockSizeUrls, - mockSize, - mockSpoilerOverlay, - } = createMockElementWithPicture('jpg'); + const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } = + createMockElementWithPicture('jpg'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); @@ -243,13 +230,8 @@ describe('Image utils', () => { }); it('should show the correct thumbnail image for gif extension', () => { - const { - mockElement, - mockSizeImage, - mockSizeUrls, - mockSize, - mockSpoilerOverlay, - } = createMockElementWithPicture('gif'); + const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } = + createMockElementWithPicture('gif'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); @@ -260,13 +242,8 @@ describe('Image utils', () => { }); it('should show the correct thumbnail image for webm extension', () => { - const { - mockElement, - mockSpoilerOverlay, - mockSizeImage, - mockSizeUrls, - mockSize, - } = createMockElementWithPicture('webm'); + const { mockElement, mockSpoilerOverlay, mockSizeImage, mockSizeUrls, mockSize } = + createMockElementWithPicture('webm'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif')); @@ -284,12 +261,10 @@ describe('Image utils', () => { }); const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => { - const { - mockElement, - mockSizeImage, - mockSizeUrls, - mockSpoilerOverlay, - } = createMockElementWithPicture('jpg', size); + const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture( + 'jpg', + size, + ); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[size]); @@ -312,12 +287,10 @@ describe('Image utils', () => { it('should NOT set srcset on img if thumbUri is a gif at small size', () => { const mockSize = 'small'; - const { - mockElement, - mockSizeImage, - mockSizeUrls, - mockSpoilerOverlay, - } = createMockElementWithPicture('gif', mockSize); + const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture( + 'gif', + mockSize, + ); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); @@ -336,12 +309,7 @@ describe('Image utils', () => { }); it('should return false if img source already matches thumbUri', () => { - const { - mockElement, - mockSizeImage, - mockSizeUrls, - mockSize, - } = createMockElementWithPicture('jpg'); + const { mockElement, mockSizeImage, mockSizeUrls, mockSize } = createMockElementWithPicture('jpg'); mockSizeImage.src = mockSizeUrls[mockSize]; const result = showThumb(mockElement); expect(result).toBe(false); @@ -408,8 +376,7 @@ describe('Image utils', () => { expect(querySelectorSpy).toHaveBeenCalledTimes(2); expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video'); - } - finally { + } finally { querySelectorSpy.mockRestore(); } }); @@ -430,8 +397,7 @@ describe('Image utils', () => { expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img'); expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`); expect(mockVideo).not.toHaveClass(hiddenClass); - } - finally { + } finally { querySelectorSpy.mockRestore(); pauseSpy.mockRestore(); } @@ -458,8 +424,7 @@ describe('Image utils', () => { expect(mockVideo).toBeEmptyDOMElement(); expect(mockVideo).toHaveClass(hiddenClass); expect(pauseSpy).toHaveBeenCalled(); - } - finally { + } finally { pauseSpy.mockRestore(); } }); @@ -482,8 +447,7 @@ describe('Image utils', () => { expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img'); expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`); - } - finally { + } finally { imgQuerySelectorSpy.mockRestore(); pictureQuerySelectorSpy.mockRestore(); } diff --git a/assets/js/utils/__tests__/local-autocompleter.spec.ts b/assets/js/utils/__tests__/local-autocompleter.spec.ts index 182e1308..2310c92d 100644 --- a/assets/js/utils/__tests__/local-autocompleter.spec.ts +++ b/assets/js/utils/__tests__/local-autocompleter.spec.ts @@ -7,7 +7,7 @@ describe('Local Autocompleter', () => { let mockData: ArrayBuffer; const defaultK = 5; - beforeAll(async() => { + beforeAll(async () => { const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin'); /** * Read pre-generated binary autocomplete data @@ -78,9 +78,7 @@ describe('Local Autocompleter', () => { it('should return namespaced suggestions without including namespace', () => { const result = localAc.topK('test', defaultK); - expect(result).toEqual([ - expect.objectContaining({ name: 'artist:test', imageCount: 1 }), - ]); + expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]); }); it('should return only the required number of suggestions', () => { diff --git a/assets/js/utils/__tests__/requests.spec.ts b/assets/js/utils/__tests__/requests.spec.ts index fd1af19e..db8395b9 100644 --- a/assets/js/utils/__tests__/requests.spec.ts +++ b/assets/js/utils/__tests__/requests.spec.ts @@ -29,7 +29,7 @@ describe('Request utils', () => { headers: { 'Content-Type': 'application/json', 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'xmlhttprequest' + 'x-requested-with': 'xmlhttprequest', }, }); }); @@ -46,12 +46,12 @@ describe('Request utils', () => { headers: { 'Content-Type': 'application/json', 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'xmlhttprequest' + 'x-requested-with': 'xmlhttprequest', }, body: JSON.stringify({ ...mockBody, - _method: mockVerb - }) + _method: mockVerb, + }), }); }); }); @@ -64,7 +64,7 @@ describe('Request utils', () => { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'xmlhttprequest' + 'x-requested-with': 'xmlhttprequest', }, }); }); diff --git a/assets/js/utils/__tests__/store.spec.ts b/assets/js/utils/__tests__/store.spec.ts index b99745bb..bb8d0168 100644 --- a/assets/js/utils/__tests__/store.spec.ts +++ b/assets/js/utils/__tests__/store.spec.ts @@ -60,9 +60,11 @@ describe('Store utilities', () => { }, }; const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[]; - setStorageValue(initialValueKeys.reduce((acc, key) => { - return { ...acc, [key]: JSON.stringify(initialValues[key]) }; - }, {})); + setStorageValue( + initialValueKeys.reduce((acc, key) => { + return { ...acc, [key]: JSON.stringify(initialValues[key]) }; + }, {}), + ); initialValueKeys.forEach((key, i) => { const result = store.get(key); @@ -166,7 +168,11 @@ describe('Store utilities', () => { expect(setItemSpy).toHaveBeenCalledTimes(2); expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue)); - expect(setItemSpy).toHaveBeenNthCalledWith(2, mockKey + lastUpdatedSuffix, JSON.stringify(initialDateNow + mockMaxAge)); + expect(setItemSpy).toHaveBeenNthCalledWith( + 2, + mockKey + lastUpdatedSuffix, + JSON.stringify(initialDateNow + mockMaxAge), + ); }); }); diff --git a/assets/js/utils/__tests__/tag.spec.ts b/assets/js/utils/__tests__/tag.spec.ts index 61a196b8..3598ee57 100644 --- a/assets/js/utils/__tests__/tag.spec.ts +++ b/assets/js/utils/__tests__/tag.spec.ts @@ -57,7 +57,7 @@ describe('Tag utilities', () => { }); describe('getHiddenTags', () => { - it('should get a single hidden tag\'s information', () => { + it("should get a single hidden tag's information", () => { window.booru.hiddenTagList = [1, 1]; const result = getHiddenTags(); @@ -72,12 +72,7 @@ describe('Tag utilities', () => { const result = getHiddenTags(); expect(result).toHaveLength(4); - expect(result).toEqual([ - mockTagInfo[3], - mockTagInfo[2], - mockTagInfo[1], - mockTagInfo[4], - ]); + expect(result).toEqual([mockTagInfo[3], mockTagInfo[2], mockTagInfo[1], mockTagInfo[4]]); }); }); @@ -91,7 +86,7 @@ describe('Tag utilities', () => { expect(result).toHaveLength(0); }); - it('should get a single spoilered tag\'s information', () => { + it("should get a single spoilered tag's information", () => { window.booru.spoileredTagList = [1, 1]; window.booru.ignoredTagList = []; window.booru.spoilerType = getEnabledSpoilerType(); @@ -110,12 +105,7 @@ describe('Tag utilities', () => { const result = getSpoileredTags(); expect(result).toHaveLength(4); - expect(result).toEqual([ - mockTagInfo[2], - mockTagInfo[3], - mockTagInfo[1], - mockTagInfo[4], - ]); + expect(result).toEqual([mockTagInfo[2], mockTagInfo[3], mockTagInfo[1], mockTagInfo[4]]); }); it('should omit ignored tags from the list', () => { @@ -125,10 +115,7 @@ describe('Tag utilities', () => { const result = getSpoileredTags(); expect(result).toHaveLength(2); - expect(result).toEqual([ - mockTagInfo[1], - mockTagInfo[4], - ]); + expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]); }); }); @@ -140,10 +127,7 @@ describe('Tag utilities', () => { const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]); expect(result).toHaveLength(mockImageTags.length); - expect(result).toEqual([ - mockTagInfo[1], - mockTagInfo[4], - ]); + expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]); }); it('should return empty array if data attribute is missing', () => { @@ -174,12 +158,16 @@ describe('Tag utilities', () => { it('should return the correct value for two tags', () => { const result = displayTags([mockTagInfo[1], mockTagInfo[4]]); - expect(result).toEqual(`${mockTagInfo[1].name}, ${mockTagInfo[4].name}`); + expect(result).toEqual( + `${mockTagInfo[1].name}, ${mockTagInfo[4].name}`, + ); }); it('should return the correct value for three tags', () => { const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]); - expect(result).toEqual(`${mockTagInfo[1].name}, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}`); + expect(result).toEqual( + `${mockTagInfo[1].name}, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}`, + ); }); it('should escape HTML in the tag name', () => { diff --git a/assets/js/utils/dom.ts b/assets/js/utils/dom.ts index 07705cbe..48546122 100644 --- a/assets/js/utils/dom.ts +++ b/assets/js/utils/dom.ts @@ -5,14 +5,20 @@ type PhilomenaInputElements = HTMLTextAreaElement | HTMLInputElement | HTMLButto /** * Get the first matching element */ -export function $(selector: string, context: Pick = document): E | null { +export function $( + selector: string, + context: Pick = document, +): E | null { return context.querySelector(selector); } /** * Get every matching element as an array */ -export function $$(selector: string, context: Pick = document): E[] { +export function $$( + selector: string, + context: Pick = document, +): E[] { const elements = context.querySelectorAll(selector); return [...elements]; @@ -52,7 +58,10 @@ export function removeEl(...elements: E[] | ConcatArray el.parentNode?.removeChild(el)); } -export function makeEl(tag: Tag, attr?: Partial): HTMLElementTagNameMap[Tag] { +export function makeEl( + tag: Tag, + attr?: Partial, +): HTMLElementTagNameMap[Tag] { const el = document.createElement(tag); if (attr) { for (const prop in attr) { @@ -65,7 +74,10 @@ export function makeEl(tag: Tag, attr?: return el; } -export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick = document): VoidFunction { +export function onLeftClick( + callback: (e: MouseEvent) => boolean | void, + context: Pick = document, +): VoidFunction { const handler: typeof callback = event => { if (event.button === 0) callback(event); }; @@ -80,22 +92,17 @@ export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context export function whenReady(callback: VoidFunction): void { if (document.readyState !== 'loading') { callback(); - } - else { + } else { document.addEventListener('DOMContentLoaded', callback); } } export function escapeHtml(html: string): string { - return html.replace(/&/g, '&') - .replace(/>/g, '>') - .replace(//g, '>').replace(/(of: Node): N { diff --git a/assets/js/utils/draggable.ts b/assets/js/utils/draggable.ts index 71d0f8bf..72706648 100644 --- a/assets/js/utils/draggable.ts +++ b/assets/js/utils/draggable.ts @@ -47,8 +47,7 @@ function drop(event: DragEvent, target: HTMLElement) { if (event.clientX < detX) { target.insertAdjacentElement('beforebegin', dragSrcEl); - } - else { + } else { target.insertAdjacentElement('afterend', dragSrcEl); } } @@ -63,12 +62,12 @@ function dragEnd(event: DragEvent, target: HTMLElement) { export function initDraggables() { const draggableSelector = '.drag-container [draggable]'; - delegate(document, 'dragstart', { [draggableSelector]: dragStart}); - delegate(document, 'dragover', { [draggableSelector]: dragOver}); - delegate(document, 'dragenter', { [draggableSelector]: dragEnter}); - delegate(document, 'dragleave', { [draggableSelector]: dragLeave}); - delegate(document, 'dragend', { [draggableSelector]: dragEnd}); - delegate(document, 'drop', { [draggableSelector]: drop}); + delegate(document, 'dragstart', { [draggableSelector]: dragStart }); + delegate(document, 'dragover', { [draggableSelector]: dragOver }); + delegate(document, 'dragenter', { [draggableSelector]: dragEnter }); + delegate(document, 'dragleave', { [draggableSelector]: dragLeave }); + delegate(document, 'dragend', { [draggableSelector]: dragEnd }); + delegate(document, 'drop', { [draggableSelector]: drop }); } export function clearDragSource() { diff --git a/assets/js/utils/events.ts b/assets/js/utils/events.ts index e92b0109..70460bf8 100644 --- a/assets/js/utils/events.ts +++ b/assets/js/utils/events.ts @@ -3,16 +3,16 @@ import '../../types/ujs'; export interface PhilomenaAvailableEventsMap { - dragstart: DragEvent, - dragover: DragEvent, - dragenter: DragEvent, - dragleave: DragEvent, - dragend: DragEvent, - drop: DragEvent, - click: MouseEvent, - submit: Event, - reset: Event, - fetchcomplete: FetchcompleteEvent + dragstart: DragEvent; + dragover: DragEvent; + dragenter: DragEvent; + dragleave: DragEvent; + dragend: DragEvent; + drop: DragEvent; + click: MouseEvent; + submit: Event; + reset: Event; + fetchcomplete: FetchcompleteEvent; } export interface PhilomenaEventElement { @@ -20,7 +20,7 @@ export interface PhilomenaEventElement { type: K, // eslint-disable-next-line @typescript-eslint/no-explicit-any listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any, - options?: boolean | AddEventListenerOptions | undefined + options?: boolean | AddEventListenerOptions | undefined, ): void; } @@ -30,19 +30,23 @@ export function fire(el: El, event: string, detail: D) { export function on( node: PhilomenaEventElement, - event: K, selector: string, func: ((e: PhilomenaAvailableEventsMap[K], target: Element) => boolean) + event: K, + selector: string, + func: (e: PhilomenaAvailableEventsMap[K], target: Element) => boolean, ) { delegate(node, event, { [selector]: func }); } export function leftClick(func: (e: E, t: Target) => void) { - return (event: E, target: Target) => { if (event.button === 0) return func(event, target); }; + return (event: E, target: Target) => { + if (event.button === 0) return func(event, target); + }; } export function delegate( node: PhilomenaEventElement, event: K, - selectors: Record void | boolean)> + selectors: Record void | boolean>, ) { node.addEventListener(event, e => { for (const selector in selectors) { diff --git a/assets/js/utils/image.ts b/assets/js/utils/image.ts index 99ab2722..da23f2ea 100644 --- a/assets/js/utils/image.ts +++ b/assets/js/utils/image.ts @@ -53,8 +53,7 @@ export function showThumb(img: HTMLDivElement) { if (uris[size].indexOf('.webm') !== -1) { overlay.classList.remove('hidden'); overlay.innerHTML = 'WebM'; - } - else { + } else { overlay.classList.add('hidden'); } @@ -118,7 +117,9 @@ export function spoilerThumb(img: HTMLDivElement, spoilerUri: string, reason: st switch (window.booru.spoilerType) { case 'click': - img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); }); + img.addEventListener('click', event => { + if (showThumb(img)) event.preventDefault(); + }); img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason)); break; case 'hover': diff --git a/assets/js/utils/local-autocompleter.ts b/assets/js/utils/local-autocompleter.ts index 73d88f92..ec3ba162 100644 --- a/assets/js/utils/local-autocompleter.ts +++ b/assets/js/utils/local-autocompleter.ts @@ -70,7 +70,7 @@ export class LocalAutocompleter { associations.push(this.view.getUint32(location + 1 + nameLength + 1 + i * 4, true)); } - return [ name, associations ]; + return [name, associations]; } /** @@ -79,14 +79,14 @@ export class LocalAutocompleter { getResultAt(i: number): [string, Result] { const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true); const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true); - const [ name, associations ] = this.getTagFromLocation(nameLocation); + const [name, associations] = this.getTagFromLocation(nameLocation); if (imageCount < 0) { // This is actually an alias, so follow it - return [ name, this.getResultAt(-imageCount - 1)[1] ]; + return [name, this.getResultAt(-imageCount - 1)[1]]; } - return [ name, { name, imageCount, associations } ]; + return [name, { name, imageCount, associations }]; } /** @@ -100,7 +100,11 @@ export class LocalAutocompleter { /** * Perform a binary search to fetch all results matching a condition. */ - scanResults(getResult: (i: number) => [string, Result], compare: (name: string) => number, results: Record) { + scanResults( + getResult: (i: number) => [string, Result], + compare: (name: string) => number, + results: Record, + ) { const unfilter = store.get('unfilter_tag_suggestions'); let min = 0; @@ -109,14 +113,13 @@ export class LocalAutocompleter { const hiddenTags = window.booru.hiddenTagList; while (min < max - 1) { - const med = min + (max - min) / 2 | 0; + const med = (min + (max - min) / 2) | 0; const sortKey = getResult(med)[0]; if (compare(sortKey) >= 0) { // too large, go left max = med; - } - else { + } else { // too small, go right min = med; } @@ -124,7 +127,7 @@ export class LocalAutocompleter { // Scan forward until no more matches occur while (min < this.numTags - 1) { - const [ sortKey, result ] = getResult(++min); + const [sortKey, result] = getResult(++min); if (compare(sortKey) !== 0) { break; } diff --git a/assets/js/utils/requests.ts b/assets/js/utils/requests.ts index e71a7662..33a045a4 100644 --- a/assets/js/utils/requests.ts +++ b/assets/js/utils/requests.ts @@ -9,7 +9,7 @@ export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record { credentials: 'same-origin', headers: { 'x-csrf-token': window.booru.csrfToken, - 'x-requested-with': 'xmlhttprequest' + 'x-requested-with': 'xmlhttprequest', }, }); } diff --git a/assets/js/utils/store.ts b/assets/js/utils/store.ts index a71d4256..d4b8579d 100644 --- a/assets/js/utils/store.ts +++ b/assets/js/utils/store.ts @@ -5,13 +5,11 @@ export const lastUpdatedSuffix = '__lastUpdated'; export default { - set(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)); return true; - } - catch { + } catch { return false; } }, @@ -21,8 +19,7 @@ export default { if (value === null) return null; try { return JSON.parse(value); - } - catch { + } catch { return value as unknown as Value; } }, @@ -31,8 +28,7 @@ export default { try { localStorage.removeItem(key); return true; - } - catch { + } catch { return false; } }, @@ -61,5 +57,4 @@ export default { return lastUpdatedTime === null || Date.now() > lastUpdatedTime; }, - }; diff --git a/assets/js/utils/tag.ts b/assets/js/utils/tag.ts index 26c6bdf9..af2b1095 100644 --- a/assets/js/utils/tag.ts +++ b/assets/js/utils/tag.ts @@ -57,8 +57,10 @@ export function imageHitsComplex(img: HTMLElement, matchComplex: AstMatcher) { } export function displayTags(tags: TagData[]): string { - const mainTag = tags[0], otherTags = tags.slice(1); - let list = escapeHtml(mainTag.name), extras; + const mainTag = tags[0]; + const otherTags = tags.slice(1); + let list = escapeHtml(mainTag.name); + let extras; if (otherTags.length > 0) { extras = otherTags.map(tag => escapeHtml(tag.name)).join(', '); diff --git a/assets/js/when-ready.ts b/assets/js/when-ready.ts index efefea64..a550ff28 100644 --- a/assets/js/when-ready.ts +++ b/assets/js/when-ready.ts @@ -2,40 +2,39 @@ * Functions to execute when the DOM is ready */ -import { whenReady } from './utils/dom'; +import { whenReady } from './utils/dom'; -import { listenAutocomplete } from './autocomplete'; -import { loadBooruData } from './booru'; -import { registerEvents } from './boorujs'; -import { setupBurgerMenu } from './burger'; -import { bindCaptchaLinks } from './captcha'; -import { setupComments } from './comment'; -import { setupDupeReports } from './duplicate_reports'; -import { setSesCookie } from './fp'; -import { setupGalleryEditing } from './galleries'; +import { listenAutocomplete } from './autocomplete'; +import { loadBooruData } from './booru'; +import { registerEvents } from './boorujs'; +import { setupBurgerMenu } from './burger'; +import { bindCaptchaLinks } from './captcha'; +import { setupComments } from './comment'; +import { setupDupeReports } from './duplicate_reports'; +import { setSesCookie } from './fp'; +import { setupGalleryEditing } from './galleries'; import { initImagesClientside } from './imagesclientside'; -import { bindImageTarget } from './image_expansion'; -import { setupEvents } from './misc'; -import { setupNotifications } from './notifications'; -import { setupPreviews } from './preview'; -import { setupQuickTag } from './quick-tag'; -import { initializeListener } from './resizablemedia'; -import { setupSettings } from './settings'; -import { listenForKeys } from './shortcuts'; -import { initTagDropdown } from './tags'; -import { setupTagListener } from './tagsinput'; -import { setupTagEvents } from './tagsmisc'; -import { setupTimestamps } from './timeago'; -import { setupImageUpload } from './upload'; -import { setupSearch } from './search'; -import { setupToolbar } from './markdowntoolbar'; -import { hideStaffTools } from './staffhider'; -import { pollOptionCreator } from './poll'; -import { warnAboutPMs } from './pmwarning'; -import { imageSourcesCreator } from './sources'; +import { bindImageTarget } from './image_expansion'; +import { setupEvents } from './misc'; +import { setupNotifications } from './notifications'; +import { setupPreviews } from './preview'; +import { setupQuickTag } from './quick-tag'; +import { initializeListener } from './resizablemedia'; +import { setupSettings } from './settings'; +import { listenForKeys } from './shortcuts'; +import { initTagDropdown } from './tags'; +import { setupTagListener } from './tagsinput'; +import { setupTagEvents } from './tagsmisc'; +import { setupTimestamps } from './timeago'; +import { setupImageUpload } from './upload'; +import { setupSearch } from './search'; +import { setupToolbar } from './markdowntoolbar'; +import { hideStaffTools } from './staffhider'; +import { pollOptionCreator } from './poll'; +import { warnAboutPMs } from './pmwarning'; +import { imageSourcesCreator } from './sources'; whenReady(() => { - loadBooruData(); listenAutocomplete(); registerEvents(); @@ -65,5 +64,4 @@ whenReady(() => { pollOptionCreator(); warnAboutPMs(); imageSourcesCreator(); - }); diff --git a/assets/test/fix-event-listeners.ts b/assets/test/fix-event-listeners.ts index d4e0a8bf..899d7014 100644 --- a/assets/test/fix-event-listeners.ts +++ b/assets/test/fix-event-listeners.ts @@ -8,7 +8,7 @@ export function fixEventListeners(t: EventTarget) { eventListeners = {}; const oldAddEventListener = t.addEventListener; - t.addEventListener = function(type: string, listener: any, options: any): void { + t.addEventListener = function (type: string, listener: any, options: any): void { eventListeners[type] = eventListeners[type] || []; eventListeners[type].push(listener); return oldAddEventListener(type, listener, options); diff --git a/assets/test/mock-storage.ts b/assets/test/mock-storage.ts index d7b337a9..1f283f29 100644 --- a/assets/test/mock-storage.ts +++ b/assets/test/mock-storage.ts @@ -2,7 +2,9 @@ import { MockInstance } from 'vitest'; type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem'; -export function mockStorage(options: Pick): { [k in `${Keys}Spy`]: MockInstance } { +export function mockStorage( + options: Pick, +): { [k in `${Keys}Spy`]: MockInstance } { const getItemSpy = 'getItem' in options ? vi.spyOn(Storage.prototype, 'getItem') : undefined; const setItemSpy = 'setItem' in options ? vi.spyOn(Storage.prototype, 'setItem') : undefined; const removeItemSpy = 'removeItem' in options ? vi.spyOn(Storage.prototype, 'removeItem') : undefined; @@ -33,18 +35,18 @@ type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: MockInstance } & { * Forces the mock storage back to its default (empty) state * @param value */ - clearStorage: VoidFunction, + clearStorage: VoidFunction; /** * Forces the mock storage to be in the specific state provided as the parameter * @param value */ - setStorageValue: (value: Record) => void, + setStorageValue: (value: Record) => void; /** * Forces the mock storage to throw an error for the duration of the provided function's execution, * or in case a promise is returned by the function, until that promise is resolved. */ - forceStorageError: (func: (...args: Args[]) => Return | Promise) => void -} + forceStorageError: (func: (...args: Args[]) => Return | Promise) => void; +}; export function mockStorageImpl(): MockStorageImplApi { let shouldThrow = false; diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts index c7d92545..7c0d2d62 100644 --- a/assets/test/vitest-setup.ts +++ b/assets/test/vitest-setup.ts @@ -20,7 +20,7 @@ window.booru = { hiddenFilter: matchNone(), spoileredFilter: matchNone(), interactions: [], - tagsVersion: 5 + tagsVersion: 5, }; // https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038 @@ -30,6 +30,6 @@ Object.assign(globalThis, { URL, Blob }); // Prevents an error when calling `form.submit()` directly in // the code that is being tested -HTMLFormElement.prototype.submit = function() { +HTMLFormElement.prototype.submit = function () { fireEvent.submit(this); }; diff --git a/assets/types/ujs.ts b/assets/types/ujs.ts index f9cb88f5..5853216d 100644 --- a/assets/types/ujs.ts +++ b/assets/types/ujs.ts @@ -2,7 +2,7 @@ export {}; declare global { interface FetchcompleteEvent extends CustomEvent { - target: HTMLElement, + target: HTMLElement; } interface GlobalEventHandlersEventMap { diff --git a/assets/vite.config.ts b/assets/vite.config.ts index 3ec68f35..53870983 100644 --- a/assets/vite.config.ts +++ b/assets/vite.config.ts @@ -7,13 +7,14 @@ import { defineConfig, UserConfig, ConfigEnv } from 'vite'; export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { const isDev = command !== 'build' && mode !== 'test'; - const themeNames = - fs.readdirSync(path.resolve(__dirname, 'css/themes/')).map(name => { - const m = name.match(/([-a-z]+).scss/); + const themeNames = fs.readdirSync(path.resolve(__dirname, 'css/themes/')).map(name => { + const m = name.match(/([-a-z]+).scss/); - if (m) { return m[1]; } - return null; - }); + if (m) { + return m[1]; + } + return null; + }); const themes = new Map(); @@ -31,8 +32,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { resolve: { alias: { common: path.resolve(__dirname, 'css/common/'), - views: path.resolve(__dirname, 'css/views/') - } + views: path.resolve(__dirname, 'css/views/'), + }, }, build: { target: ['es2016', 'chrome67', 'firefox62', 'edge18', 'safari12'], @@ -44,19 +45,19 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { rollupOptions: { input: { 'js/app': './js/app.ts', - ...Object.fromEntries(themes) + ...Object.fromEntries(themes), }, output: { entryFileNames: '[name].js', chunkFileNames: '[name].js', - assetFileNames: '[name][extname]' - } - } + assetFileNames: '[name][extname]', + }, + }, }, css: { - postcss: { - plugins: [autoprefixer] - } + postcss: { + plugins: [autoprefixer], + }, }, test: { globals: true, @@ -67,11 +68,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { coverage: { reporter: ['text', 'html'], include: ['js/**/*.{js,ts}'], - exclude: [ - 'node_modules/', - '.*\\.test\\.ts$', - '.*\\.d\\.ts$', - ], + exclude: ['node_modules/', '.*\\.test\\.ts$', '.*\\.d\\.ts$'], thresholds: { statements: 0, branches: 0, @@ -83,8 +80,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { functions: 100, lines: 100, }, - } - } - } + }, + }, + }, }; }); From aea131afe45424a4a4609c58b1646f9ef908334b Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 6 Jul 2024 00:37:35 -0400 Subject: [PATCH 005/115] New notifications UI: separated by category --- assets/css/common/_base.scss | 1 + assets/css/views/_notifications.scss | 11 +++ lib/philomena/notifications.ex | 73 +++++++++++++-- lib/philomena/notifications/category.ex | 93 +++++++++++++++++++ .../notification/category_controller.ex | 33 +++++++ .../controllers/notification_controller.ex | 27 +----- lib/philomena_web/router.ex | 1 + .../notification/_notification.html.slime | 2 +- .../notification/category/show.html.slime | 28 ++++++ .../templates/notification/index.html.slime | 19 ++-- .../views/notification/category_view.ex | 5 + lib/philomena_web/views/notification_view.ex | 22 +++++ 12 files changed, 276 insertions(+), 39 deletions(-) create mode 100644 assets/css/views/_notifications.scss create mode 100644 lib/philomena/notifications/category.ex create mode 100644 lib/philomena_web/controllers/notification/category_controller.ex create mode 100644 lib/philomena_web/templates/notification/category/show.html.slime create mode 100644 lib/philomena_web/views/notification/category_view.ex diff --git a/assets/css/common/_base.scss b/assets/css/common/_base.scss index 7aa8ec97..0b3452dd 100644 --- a/assets/css/common/_base.scss +++ b/assets/css/common/_base.scss @@ -480,6 +480,7 @@ span.stat { @import "views/filters"; @import "views/galleries"; @import "views/images"; +@import "views/notifications"; @import "views/pages"; @import "views/polls"; @import "views/posts"; diff --git a/assets/css/views/_notifications.scss b/assets/css/views/_notifications.scss new file mode 100644 index 00000000..8c4f327e --- /dev/null +++ b/assets/css/views/_notifications.scss @@ -0,0 +1,11 @@ +.notification-type-block:not(:last-child) { + margin-bottom: 20px; +} + +.notification { + margin-bottom: 0; +} + +.notification:not(:last-child) { + border-bottom: 0; +} diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index a82094b3..cbff31ee 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -6,19 +6,82 @@ defmodule Philomena.Notifications do import Ecto.Query, warn: false alias Philomena.Repo + alias Philomena.Notifications.Category alias Philomena.Notifications.Notification + alias Philomena.Notifications.UnreadNotification + alias Philomena.Polymorphic @doc """ - Returns the list of notifications. + Returns the list of unread notifications of the given type. + + The set of valid types is `t:Philomena.Notifications.Category.t/0`. ## Examples - iex> list_notifications() + iex> unread_notifications_for_user_and_type(user, :image_comment, ...) [%Notification{}, ...] """ - def list_notifications do - Repo.all(Notification) + def unread_notifications_for_user_and_type(user, type, pagination) do + notifications = + user + |> unread_query_for_type(type) + |> Repo.paginate(pagination) + + put_in(notifications.entries, load_associations(notifications.entries)) + end + + @doc """ + Gather up and return the top N notifications for the user, for each type of + unread notification currently existing. + + ## Examples + + iex> unread_notifications_for_user(user) + [ + forum_topic: [%Notification{...}, ...], + forum_post: [%Notification{...}, ...], + image_comment: [%Notification{...}, ...] + ] + + """ + def unread_notifications_for_user(user, n) do + Category.types() + |> Enum.map(fn type -> + q = + user + |> unread_query_for_type(type) + |> limit(^n) + + # Use a subquery to ensure the order by is applied to the + # subquery results only, and not the main query results + from(n in subquery(q)) + end) + |> union_all_queries() + |> Repo.all() + |> load_associations() + |> Enum.group_by(&Category.notification_type/1) + |> Enum.sort_by(fn {k, _v} -> k end) + end + + defp unread_query_for_type(user, type) do + from n in Category.query_for_type(type), + join: un in UnreadNotification, + on: un.notification_id == n.id, + where: un.user_id == ^user.id, + order_by: [desc: :updated_at] + end + + defp union_all_queries([query | rest]) do + Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) + end + + defp load_associations(notifications) do + Polymorphic.load_polymorphic( + notifications, + actor: [actor_id: :actor_type], + actor_child: [actor_child_id: :actor_child_type] + ) end @doc """ @@ -102,8 +165,6 @@ defmodule Philomena.Notifications do Notification.changeset(notification, %{}) end - alias Philomena.Notifications.UnreadNotification - def count_unread_notifications(user) do UnreadNotification |> where(user_id: ^user.id) diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex new file mode 100644 index 00000000..775b888d --- /dev/null +++ b/lib/philomena/notifications/category.ex @@ -0,0 +1,93 @@ +defmodule Philomena.Notifications.Category do + @moduledoc """ + Notification category determination. + """ + + import Ecto.Query, warn: false + alias Philomena.Notifications.Notification + + @type t :: + :channel_live + | :forum_post + | :forum_topic + | :gallery_image + | :image_comment + | :image_merge + + @doc """ + Return a list of all supported types. + """ + def types do + [ + :channel_live, + :forum_topic, + :gallery_image, + :image_comment, + :image_merge, + :forum_post + ] + end + + @doc """ + Determine the type of a `m:Philomena.Notifications.Notification`. + """ + def notification_type(n) do + case {n.actor_type, n.actor_child_type} do + {"Channel", _} -> + :channel_live + + {"Gallery", _} -> + :gallery_image + + {"Image", "Comment"} -> + :image_comment + + {"Image", _} -> + :image_merge + + {"Topic", "Post"} -> + if n.action == "posted a new reply in" do + :forum_post + else + :forum_topic + end + end + end + + @doc """ + Returns an `m:Ecto.Query` that finds notifications for the given type. + """ + def query_for_type(type) do + base = from(n in Notification) + + case type do + :channel_live -> + where(base, [n], n.actor_type == "Channel") + + :gallery_image -> + where(base, [n], n.actor_type == "Gallery") + + :image_comment -> + where(base, [n], n.actor_type == "Image" and n.actor_child_type == "Comment") + + :image_merge -> + where(base, [n], n.actor_type == "Image" and is_nil(n.actor_child_type)) + + :forum_topic -> + where( + base, + [n], + n.actor_type == "Topic" and n.actor_child_type == "Post" and + n.action != "posted a new reply in" + ) + + :forum_post -> + where( + base, + [n], + n.actor_type == "Topic" and n.actor_child_type == "Post" and + n.action == "posted a new reply in" + ) + end + end +end diff --git a/lib/philomena_web/controllers/notification/category_controller.ex b/lib/philomena_web/controllers/notification/category_controller.ex new file mode 100644 index 00000000..76142581 --- /dev/null +++ b/lib/philomena_web/controllers/notification/category_controller.ex @@ -0,0 +1,33 @@ +defmodule PhilomenaWeb.Notification.CategoryController do + use PhilomenaWeb, :controller + + alias Philomena.Notifications + + def show(conn, params) do + type = category(params) + + notifications = + Notifications.unread_notifications_for_user_and_type( + conn.assigns.current_user, + type, + conn.assigns.scrivener + ) + + render(conn, "show.html", + title: "Notification Area", + notifications: notifications, + type: type + ) + end + + defp category(params) do + case params["id"] do + "channel_live" -> :channel_live + "gallery_image" -> :gallery_image + "image_comment" -> :image_comment + "image_merge" -> :image_merge + "forum_topic" -> :forum_topic + _ -> :forum_post + end + end +end diff --git a/lib/philomena_web/controllers/notification_controller.ex b/lib/philomena_web/controllers/notification_controller.ex index 170d504b..a21f345f 100644 --- a/lib/philomena_web/controllers/notification_controller.ex +++ b/lib/philomena_web/controllers/notification_controller.ex @@ -1,33 +1,10 @@ defmodule PhilomenaWeb.NotificationController do use PhilomenaWeb, :controller - alias Philomena.Notifications.{UnreadNotification, Notification} - alias Philomena.Polymorphic - alias Philomena.Repo - import Ecto.Query + alias Philomena.Notifications def index(conn, _params) do - user = conn.assigns.current_user - - notifications = - from n in Notification, - join: un in UnreadNotification, - on: un.notification_id == n.id, - where: un.user_id == ^user.id - - notifications = - notifications - |> order_by(desc: :updated_at) - |> Repo.paginate(conn.assigns.scrivener) - - entries = - notifications.entries - |> Polymorphic.load_polymorphic( - actor: [actor_id: :actor_type], - actor_child: [actor_child_id: :actor_child_type] - ) - - notifications = %{notifications | entries: entries} + notifications = Notifications.unread_notifications_for_user(conn.assigns.current_user, 15) render(conn, "index.html", title: "Notification Area", notifications: notifications) end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 7a89f4b1..31be1073 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -173,6 +173,7 @@ defmodule PhilomenaWeb.Router do scope "/notifications", Notification, as: :notification do resources "/unread", UnreadController, only: [:index] + resources "/categories", CategoryController, only: [:show] end resources "/notifications", NotificationController, only: [:index, :delete] diff --git a/lib/philomena_web/templates/notification/_notification.html.slime b/lib/philomena_web/templates/notification/_notification.html.slime index 688df590..dfc34b18 100644 --- a/lib/philomena_web/templates/notification/_notification.html.slime +++ b/lib/philomena_web/templates/notification/_notification.html.slime @@ -1,5 +1,5 @@ = if @notification.actor do - .block.block--fixed.flex id="notification-#{@notification.id}" + .block.block--fixed.flex.notification id="notification-#{@notification.id}" = if @notification.actor_type == "Image" and @notification.actor do .flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right = render PhilomenaWeb.ImageView, "_image_container.html", image: @notification.actor, size: :thumb_tiny, conn: @conn diff --git a/lib/philomena_web/templates/notification/category/show.html.slime b/lib/philomena_web/templates/notification/category/show.html.slime new file mode 100644 index 00000000..59f2f9d5 --- /dev/null +++ b/lib/philomena_web/templates/notification/category/show.html.slime @@ -0,0 +1,28 @@ +h1 Notification Area +.walloftext + = cond do + - Enum.any?(@notifications) -> + - route = fn p -> ~p"/notifications/categories/#{@type}?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn + + .block.notification-type-block + .block__header + span.block__header__title = name_of_type(@type) + .block__header.block__header__sub + = pagination + + div + = for notification <- @notifications do + = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + + .block__header.block__header--light + = pagination + + - true -> + p You currently have no notifications of this category. + p + ' To get notifications on new comments and forum posts, click the + ' 'Subscribe' button in the bar at the top of an image or forum topic. + + a.button href=~p"/notifications" + ' View all notifications diff --git a/lib/philomena_web/templates/notification/index.html.slime b/lib/philomena_web/templates/notification/index.html.slime index 9cec1086..fa957442 100644 --- a/lib/philomena_web/templates/notification/index.html.slime +++ b/lib/philomena_web/templates/notification/index.html.slime @@ -1,14 +1,19 @@ -- route = fn p -> ~p"/notifications?#{p}" end - h1 Notification Area .walloftext - .block__header - = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn - = cond do - Enum.any?(@notifications) -> - = for notification <- @notifications do - = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + = for {type, notifications} <- @notifications do + .block.notification-type-block + .block__header + span.block__header__title = name_of_type(type) + + div + = for notification <- notifications do + = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + + .block__header.block__header--light + a href=~p"/notifications/categories/#{type}" + | View category - true -> p diff --git a/lib/philomena_web/views/notification/category_view.ex b/lib/philomena_web/views/notification/category_view.ex new file mode 100644 index 00000000..148d94f5 --- /dev/null +++ b/lib/philomena_web/views/notification/category_view.ex @@ -0,0 +1,5 @@ +defmodule PhilomenaWeb.Notification.CategoryView do + use PhilomenaWeb, :view + + defdelegate name_of_type(type), to: PhilomenaWeb.NotificationView +end diff --git a/lib/philomena_web/views/notification_view.ex b/lib/philomena_web/views/notification_view.ex index 52d05201..dcaf81dd 100644 --- a/lib/philomena_web/views/notification_view.ex +++ b/lib/philomena_web/views/notification_view.ex @@ -13,4 +13,26 @@ defmodule PhilomenaWeb.NotificationView do def notification_template_path(actor_type) do @template_paths[actor_type] end + + def name_of_type(notification_type) do + case notification_type do + :channel_live -> + "Live channels" + + :forum_post -> + "New replies in topics" + + :forum_topic -> + "New topics" + + :gallery_image -> + "Updated galleries" + + :image_comment -> + "New replies on images" + + :image_merge -> + "Image merges" + end + end end From 420099890c1bf60b1a8562d907a2a2ee1980f402 Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 4 Jul 2024 19:09:56 -0400 Subject: [PATCH 006/115] Extended source validation --- lib/philomena/images/source.ex | 6 ++++-- lib/philomena/images/source_differ.ex | 10 ++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/philomena/images/source.ex b/lib/philomena/images/source.ex index 476872b9..3936383f 100644 --- a/lib/philomena/images/source.ex +++ b/lib/philomena/images/source.ex @@ -13,7 +13,9 @@ defmodule Philomena.Images.Source do @doc false def changeset(source, attrs) do source - |> cast(attrs, []) - |> validate_required([]) + |> cast(attrs, [:source]) + |> validate_required([:source]) + |> validate_format(:source, ~r/\Ahttps?:\/\//) + |> validate_length(:source, max: 255) end end diff --git a/lib/philomena/images/source_differ.ex b/lib/philomena/images/source_differ.ex index 8ac29a08..31a3b5b8 100644 --- a/lib/philomena/images/source_differ.ex +++ b/lib/philomena/images/source_differ.ex @@ -1,6 +1,5 @@ defmodule Philomena.Images.SourceDiffer do import Ecto.Changeset - alias Philomena.Images.Source def diff_input(changeset, old_sources, new_sources) do old_set = MapSet.new(flatten_input(old_sources)) @@ -13,12 +12,11 @@ defmodule Philomena.Images.SourceDiffer do {sources, actually_added, actually_removed} = apply_changes(source_set, added_sources, removed_sources) - image_id = fetch_field!(changeset, :id) - changeset + |> cast(source_params(sources), []) |> put_change(:added_sources, actually_added) |> put_change(:removed_sources, actually_removed) - |> put_assoc(:sources, source_structs(image_id, sources)) + |> cast_assoc(:sources) end defp apply_changes(source_set, added_set, removed_set) do @@ -44,8 +42,8 @@ defmodule Philomena.Images.SourceDiffer do {sources, actually_added, actually_removed} end - defp source_structs(image_id, sources) do - Enum.map(sources, &%Source{image_id: image_id, source: &1}) + defp source_params(sources) do + %{sources: Enum.map(sources, &%{source: &1})} end defp flatten_input(input) when is_map(input) do From 161a5faf570b29f76a39eff87ac6af8c34a0a82d Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 9 Jul 2024 21:20:00 -0400 Subject: [PATCH 007/115] Shorten truncated utc_now pattern --- lib/philomena/adverts/recorder.ex | 2 +- lib/philomena/artist_links/artist_link.ex | 11 +++++------ lib/philomena/badges/award.ex | 4 +--- lib/philomena/comments.ex | 2 +- lib/philomena/conversations/conversation.ex | 3 +-- lib/philomena/images.ex | 6 +++--- lib/philomena/images/image.ex | 6 ++---- lib/philomena/interactions.ex | 2 +- lib/philomena/poll_votes.ex | 2 +- lib/philomena/posts.ex | 4 ++-- lib/philomena/schema/approval.ex | 4 +--- lib/philomena/tag_changes.ex | 2 +- lib/philomena/topics.ex | 2 +- lib/philomena/topics/topic.ex | 7 +++---- lib/philomena/users/user.ex | 13 ++++--------- lib/philomena_query/relative_date.ex | 14 ++++++-------- lib/philomena_web/stats_updater.ex | 2 +- lib/philomena_web/user_auth.ex | 2 +- 18 files changed, 36 insertions(+), 52 deletions(-) diff --git a/lib/philomena/adverts/recorder.ex b/lib/philomena/adverts/recorder.ex index 19e15cf5..15bc3793 100644 --- a/lib/philomena/adverts/recorder.ex +++ b/lib/philomena/adverts/recorder.ex @@ -4,7 +4,7 @@ defmodule Philomena.Adverts.Recorder do import Ecto.Query def run(%{impressions: impressions, clicks: clicks}) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) # Create insert statements for Ecto impressions = Enum.map(impressions, &impressions_insert_all(&1, now)) diff --git a/lib/philomena/artist_links/artist_link.ex b/lib/philomena/artist_links/artist_link.ex index 4116e5e3..f79a872b 100644 --- a/lib/philomena/artist_links/artist_link.ex +++ b/lib/philomena/artist_links/artist_link.ex @@ -88,11 +88,10 @@ defmodule Philomena.ArtistLinks.ArtistLink do end def contact_changeset(artist_link, user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - change(artist_link) + artist_link + |> change() |> put_change(:contacted_by_user_id, user.id) - |> put_change(:contacted_at, now) + |> put_change(:contacted_at, DateTime.utc_now(:second)) |> put_change(:aasm_state, "contacted") end @@ -111,9 +110,9 @@ defmodule Philomena.ArtistLinks.ArtistLink do defp put_next_check_at(changeset) do time = - DateTime.utc_now() + :second + |> DateTime.utc_now() |> DateTime.add(60 * 2, :second) - |> DateTime.truncate(:second) change(changeset, next_check_at: time) end diff --git a/lib/philomena/badges/award.ex b/lib/philomena/badges/award.ex index 0ee8da28..e6ca3bef 100644 --- a/lib/philomena/badges/award.ex +++ b/lib/philomena/badges/award.ex @@ -26,9 +26,7 @@ defmodule Philomena.Badges.Award do end defp put_awarded_on(%{data: %{awarded_on: nil}} = changeset) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - put_change(changeset, :awarded_on, now) + put_change(changeset, :awarded_on, DateTime.utc_now(:second)) end defp put_awarded_on(changeset), do: changeset diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 359347d0..f0ac4dc0 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -111,7 +111,7 @@ defmodule Philomena.Comments do """ def update_comment(%Comment{} = comment, editor, attrs) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) current_body = comment.body current_reason = comment.edit_reason diff --git a/lib/philomena/conversations/conversation.ex b/lib/philomena/conversations/conversation.ex index ac188f1d..77c5981d 100644 --- a/lib/philomena/conversations/conversation.ex +++ b/lib/philomena/conversations/conversation.ex @@ -67,8 +67,7 @@ defmodule Philomena.Conversations.Conversation do end defp set_last_message(changeset) do - changeset - |> change(last_message_at: DateTime.utc_now() |> DateTime.truncate(:second)) + change(changeset, last_message_at: DateTime.utc_now(:second)) end defp put_recipient(changeset) do diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index af775c4c..db95b09a 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -369,7 +369,7 @@ defmodule Philomena.Images do end defp source_change_attributes(attribution, image, source, added, user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) user_id = case user do @@ -465,7 +465,7 @@ defmodule Philomena.Images do end defp tag_change_attributes(attribution, image, tag, added, user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) user_id = case user do @@ -708,7 +708,7 @@ defmodule Philomena.Images do |> where([t], t.image_id in ^image_ids and t.tag_id in ^removed_tags) |> select([t], [t.image_id, t.tag_id]) - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) tag_change_attributes = Map.merge(tag_change_attributes, %{created_at: now, updated_at: now}) tag_attributes = %{name: "", slug: "", created_at: now, updated_at: now} diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 7b808eaa..83ce9409 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -120,11 +120,9 @@ defmodule Philomena.Images.Image do end def creation_changeset(image, attrs, attribution) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - image |> cast(attrs, [:anonymous, :source_url, :description]) - |> change(first_seen_at: now) + |> change(first_seen_at: DateTime.utc_now(:second)) |> change(attribution) |> validate_length(:description, max: 50_000, count: :bytes) |> validate_format(:source_url, ~r/\Ahttps?:\/\//) @@ -340,7 +338,7 @@ defmodule Philomena.Images.Image do def approve_changeset(image) do change(image) |> put_change(:approved, true) - |> put_change(:first_seen_at, DateTime.truncate(DateTime.utc_now(), :second)) + |> put_change(:first_seen_at, DateTime.utc_now(:second)) end def cache_changeset(image) do diff --git a/lib/philomena/interactions.ex b/lib/philomena/interactions.ex index 5ed5c8f2..8da603ba 100644 --- a/lib/philomena/interactions.ex +++ b/lib/philomena/interactions.ex @@ -72,7 +72,7 @@ defmodule Philomena.Interactions do end def migrate_interactions(source, target) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters]) new_hides = Enum.map(source.hiders, &%{image_id: target.id, user_id: &1.id, created_at: now}) diff --git a/lib/philomena/poll_votes.ex b/lib/philomena/poll_votes.ex index 910741c1..5e23f181 100644 --- a/lib/philomena/poll_votes.ex +++ b/lib/philomena/poll_votes.ex @@ -41,7 +41,7 @@ defmodule Philomena.PollVotes do """ def create_poll_votes(user, poll, attrs) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) poll_votes = filter_options(user, poll, now, attrs) Multi.new() diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index b591ce0e..fa46f406 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -50,7 +50,7 @@ defmodule Philomena.Posts do """ def create_post(topic, attributes, params \\ %{}) do - now = DateTime.utc_now() + now = DateTime.utc_now(:second) topic_query = Topic @@ -161,7 +161,7 @@ defmodule Philomena.Posts do """ def update_post(%Post{} = post, editor, attrs) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) current_body = post.body current_reason = post.edit_reason diff --git a/lib/philomena/schema/approval.ex b/lib/philomena/schema/approval.ex index 512c5aab..f78144f9 100644 --- a/lib/philomena/schema/approval.ex +++ b/lib/philomena/schema/approval.ex @@ -15,7 +15,7 @@ defmodule Philomena.Schema.Approval do %{changes: %{body: body}, valid?: true} = changeset, %User{} = user ) do - now = now_time() + now = DateTime.utc_now(:second) # 14 * 24 * 60 * 60 two_weeks = 1_209_600 @@ -40,6 +40,4 @@ defmodule Philomena.Schema.Approval do do: change(changeset, body: Regex.replace(@image_embed_regex, body, "[")) def maybe_strip_images(changeset, _user), do: changeset - - defp now_time(), do: DateTime.truncate(DateTime.utc_now(), :second) end diff --git a/lib/philomena/tag_changes.ex b/lib/philomena/tag_changes.ex index e92c35a3..2311088f 100644 --- a/lib/philomena/tag_changes.ex +++ b/lib/philomena/tag_changes.ex @@ -15,7 +15,7 @@ defmodule Philomena.TagChanges do # TODO: this is substantially similar to Images.batch_update/4. # Perhaps it should be extracted. def mass_revert(ids, attributes) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) tag_change_attributes = Map.merge(attributes, %{created_at: now, updated_at: now}) tag_attributes = %{name: "", slug: "", created_at: now, updated_at: now} diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index a2356f92..1a96f757 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -46,7 +46,7 @@ defmodule Philomena.Topics do """ def create_topic(forum, attribution, attrs \\ %{}) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) topic = %Topic{} diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex index 0db30126..7f2abbb6 100644 --- a/lib/philomena/topics/topic.ex +++ b/lib/philomena/topics/topic.ex @@ -75,11 +75,10 @@ defmodule Philomena.Topics.Topic do end def lock_changeset(topic, attrs, user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - change(topic) + topic + |> change() |> cast(attrs, [:lock_reason]) - |> put_change(:locked_at, now) + |> put_change(:locked_at, DateTime.utc_now(:second)) |> put_change(:locked_by_id, user.id) |> validate_required([:lock_reason]) end diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 182412d7..28018915 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -215,8 +215,7 @@ defmodule Philomena.Users.User do Confirms the account by setting `confirmed_at`. """ def confirm_changeset(user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - change(user, confirmed_at: now) + change(user, confirmed_at: DateTime.utc_now(:second)) end @doc """ @@ -259,9 +258,7 @@ defmodule Philomena.Users.User do end def lock_changeset(user) do - locked_at = DateTime.utc_now() |> DateTime.truncate(:second) - - change(user, locked_at: locked_at) + change(user, locked_at: DateTime.utc_now(:second)) end def unlock_changeset(user) do @@ -378,14 +375,12 @@ defmodule Philomena.Users.User do end def name_changeset(user, attrs) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - user |> cast(attrs, [:name]) |> validate_name() |> put_slug() |> unique_constraints() - |> put_change(:last_renamed_at, now) + |> put_change(:last_renamed_at, DateTime.utc_now(:second)) end def avatar_changeset(user, attrs) do @@ -428,7 +423,7 @@ defmodule Philomena.Users.User do end def deactivate_changeset(user, moderator) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) change(user, deleted_at: now, deleted_by_user_id: moderator.id) end diff --git a/lib/philomena_query/relative_date.ex b/lib/philomena_query/relative_date.ex index 35b0fc82..7790629b 100644 --- a/lib/philomena_query/relative_date.ex +++ b/lib/philomena_query/relative_date.ex @@ -117,7 +117,7 @@ defmodule PhilomenaQuery.RelativeDate do def parse_absolute(input) do case DateTime.from_iso8601(input) do {:ok, datetime, _offset} -> - {:ok, datetime |> DateTime.truncate(:second)} + {:ok, DateTime.truncate(datetime, :second)} _error -> {:error, "Parse error"} @@ -144,19 +144,17 @@ defmodule PhilomenaQuery.RelativeDate do """ @spec parse_relative(String.t()) :: {:ok, DateTime.t()} | {:error, any()} def parse_relative(input) do + now = DateTime.utc_now(:second) + case relative_date(input) do {:ok, [moon: _moon], _1, _2, _3, _4} -> - {:ok, - DateTime.utc_now() |> DateTime.add(31_536_000_000, :second) |> DateTime.truncate(:second)} + {:ok, DateTime.add(now, 31_536_000_000, :second)} {:ok, [now: _now], _1, _2, _3, _4} -> - {:ok, DateTime.utc_now() |> DateTime.truncate(:second)} + {:ok, now} {:ok, [relative_date: [amount, scale, direction]], _1, _2, _3, _4} -> - {:ok, - DateTime.utc_now() - |> DateTime.add(amount * scale * direction, :second) - |> DateTime.truncate(:second)} + {:ok, DateTime.add(now, amount * scale * direction, :second)} _error -> {:error, "Parse error"} diff --git a/lib/philomena_web/stats_updater.ex b/lib/philomena_web/stats_updater.ex index 8eec95c5..b91094d8 100644 --- a/lib/philomena_web/stats_updater.ex +++ b/lib/philomena_web/stats_updater.ex @@ -48,7 +48,7 @@ defmodule PhilomenaWeb.StatsUpdater do |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) static_page = %{ title: "Statistics", diff --git a/lib/philomena_web/user_auth.ex b/lib/philomena_web/user_auth.ex index 00a9e94e..b2f3a2a0 100644 --- a/lib/philomena_web/user_auth.ex +++ b/lib/philomena_web/user_auth.ex @@ -210,7 +210,7 @@ defmodule PhilomenaWeb.UserAuth do defp signed_in_path(_conn), do: "/" defp update_usages(conn, user) do - now = DateTime.utc_now() |> DateTime.truncate(:second) + now = DateTime.utc_now(:second) UserIpUpdater.cast(user.id, conn.remote_ip, now) UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now) From fc2ab285c79df273a3159ade42551b750543564e Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 13 Jul 2024 14:38:20 -0400 Subject: [PATCH 008/115] Use compile with opts for comment queries --- lib/philomena/comments/query.ex | 3 ++- .../controllers/api/json/search/comment_controller.ex | 2 +- lib/philomena_web/controllers/comment_controller.ex | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/philomena/comments/query.ex b/lib/philomena/comments/query.ex index 9e9c8986..de2fee03 100644 --- a/lib/philomena/comments/query.ex +++ b/lib/philomena/comments/query.ex @@ -92,7 +92,8 @@ defmodule Philomena.Comments.Query do |> Parser.parse(query_string, context) end - def compile(user, query_string) do + def compile(query_string, opts \\ []) do + user = Keyword.get(opts, :user) query_string = query_string || "" case user do diff --git a/lib/philomena_web/controllers/api/json/search/comment_controller.ex b/lib/philomena_web/controllers/api/json/search/comment_controller.ex index 5dbe5e4c..6942a4ff 100644 --- a/lib/philomena_web/controllers/api/json/search/comment_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/comment_controller.ex @@ -10,7 +10,7 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentController do user = conn.assigns.current_user filter = conn.assigns.current_filter - case Query.compile(user, params["q"] || "") do + case Query.compile(params["q"], user: user) do {:ok, query} -> comments = Comment diff --git a/lib/philomena_web/controllers/comment_controller.ex b/lib/philomena_web/controllers/comment_controller.ex index 99b14f25..d64f3e16 100644 --- a/lib/philomena_web/controllers/comment_controller.ex +++ b/lib/philomena_web/controllers/comment_controller.ex @@ -13,8 +13,8 @@ defmodule PhilomenaWeb.CommentController do conn = Map.put(conn, :params, params) user = conn.assigns.current_user - user - |> Query.compile(cq) + cq + |> Query.compile(user: user) |> render_index(conn, user) end From e9eb638d792b4edb68dcee8275d679150d2f8735 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 13 Jul 2024 14:42:37 -0400 Subject: [PATCH 009/115] Use compile with opts for filter queries --- lib/philomena/filters/query.ex | 3 ++- .../controllers/api/json/search/filter_controller.ex | 2 +- lib/philomena_web/controllers/filter_controller.ex | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/philomena/filters/query.ex b/lib/philomena/filters/query.ex index 3b6bb3ef..1741103c 100644 --- a/lib/philomena/filters/query.ex +++ b/lib/philomena/filters/query.ex @@ -33,7 +33,8 @@ defmodule Philomena.Filters.Query do |> Parser.parse(query_string, context) end - def compile(user, query_string) do + def compile(query_string, opts \\ []) do + user = Keyword.get(opts, :user) query_string = query_string || "" case user do diff --git a/lib/philomena_web/controllers/api/json/search/filter_controller.ex b/lib/philomena_web/controllers/api/json/search/filter_controller.ex index 7b402065..7c4f81b5 100644 --- a/lib/philomena_web/controllers/api/json/search/filter_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/filter_controller.ex @@ -9,7 +9,7 @@ defmodule PhilomenaWeb.Api.Json.Search.FilterController do def index(conn, params) do user = conn.assigns.current_user - case Query.compile(user, params["q"] || "") do + case Query.compile(params["q"], user: user) do {:ok, query} -> filters = Filter diff --git a/lib/philomena_web/controllers/filter_controller.ex b/lib/philomena_web/controllers/filter_controller.ex index 61469ffd..cbe001da 100644 --- a/lib/philomena_web/controllers/filter_controller.ex +++ b/lib/philomena_web/controllers/filter_controller.ex @@ -13,8 +13,8 @@ defmodule PhilomenaWeb.FilterController do def index(conn, %{"fq" => fq}) do user = conn.assigns.current_user - user - |> Query.compile(fq) + fq + |> Query.compile(user: user) |> render_index(conn, user) end From 476363d4f70868e3a4cce750097f86804439515d Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 13 Jul 2024 14:47:45 -0400 Subject: [PATCH 010/115] Use compile with opts for post queries --- lib/philomena/posts/query.ex | 3 ++- .../controllers/api/json/search/post_controller.ex | 2 +- lib/philomena_web/controllers/post_controller.ex | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/philomena/posts/query.ex b/lib/philomena/posts/query.ex index 331655c7..0722b10c 100644 --- a/lib/philomena/posts/query.ex +++ b/lib/philomena/posts/query.ex @@ -90,7 +90,8 @@ defmodule Philomena.Posts.Query do |> Parser.parse(query_string, context) end - def compile(user, query_string) do + def compile(query_string, opts \\ []) do + user = Keyword.get(opts, :user) query_string = query_string || "" case user do diff --git a/lib/philomena_web/controllers/api/json/search/post_controller.ex b/lib/philomena_web/controllers/api/json/search/post_controller.ex index 919a5b13..c305de12 100644 --- a/lib/philomena_web/controllers/api/json/search/post_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/post_controller.ex @@ -9,7 +9,7 @@ defmodule PhilomenaWeb.Api.Json.Search.PostController do def index(conn, params) do user = conn.assigns.current_user - case Query.compile(user, params["q"] || "") do + case Query.compile(params["q"], user: user) do {:ok, query} -> posts = Post diff --git a/lib/philomena_web/controllers/post_controller.ex b/lib/philomena_web/controllers/post_controller.ex index 17b8fcd5..6f00ff7c 100644 --- a/lib/philomena_web/controllers/post_controller.ex +++ b/lib/philomena_web/controllers/post_controller.ex @@ -13,8 +13,8 @@ defmodule PhilomenaWeb.PostController do conn = Map.put(conn, :params, params) user = conn.assigns.current_user - user - |> Query.compile(pq) + pq + |> Query.compile(user: user) |> render_index(conn, user) end From 2f182e906875384361bbbba3dd245b56fc577a7e Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 13 Jul 2024 14:59:36 -0400 Subject: [PATCH 011/115] Use compile with opts for image queries --- lib/philomena/images/query.ex | 4 +++- lib/philomena/schema/search.ex | 2 +- .../controllers/image/navigate_controller.ex | 5 ++++- lib/philomena_web/controllers/tag_controller.ex | 2 +- lib/philomena_web/image_loader.ex | 2 +- lib/philomena_web/plugs/filter_forced_users_plug.ex | 7 +++++-- lib/philomena_web/plugs/image_filter_plug.ex | 8 +++++--- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex index 9eedcd74..2c987d1b 100644 --- a/lib/philomena/images/query.ex +++ b/lib/philomena/images/query.ex @@ -144,7 +144,9 @@ defmodule Philomena.Images.Query do |> Parser.parse(query_string, context) end - def compile(user, query_string, watch \\ false) do + def compile(query_string, opts \\ []) do + user = Keyword.get(opts, :user) + watch = Keyword.get(opts, :watch, false) query_string = query_string || "" case user do diff --git a/lib/philomena/schema/search.ex b/lib/philomena/schema/search.ex index 9b4e7e08..819823f2 100644 --- a/lib/philomena/schema/search.ex +++ b/lib/philomena/schema/search.ex @@ -5,7 +5,7 @@ defmodule Philomena.Schema.Search do def validate_search(changeset, field, user, watched \\ false) do query = changeset |> get_field(field) |> String.normalize() - output = Query.compile(user, query, watched) + output = Query.compile(query, user: user, watch: watched) case output do {:ok, _} -> diff --git a/lib/philomena_web/controllers/image/navigate_controller.ex b/lib/philomena_web/controllers/image/navigate_controller.ex index 9cb61d48..13facfae 100644 --- a/lib/philomena_web/controllers/image/navigate_controller.ex +++ b/lib/philomena_web/controllers/image/navigate_controller.ex @@ -54,7 +54,10 @@ defmodule PhilomenaWeb.Image.NavigateController do defp compile_query(conn) do user = conn.assigns.current_user - {:ok, query} = Query.compile(user, match_all_if_blank(conn.params["q"])) + {:ok, query} = + conn.params["q"] + |> match_all_if_blank() + |> Query.compile(user: user) query end diff --git a/lib/philomena_web/controllers/tag_controller.ex b/lib/philomena_web/controllers/tag_controller.ex index 82ea790a..4900ddd3 100644 --- a/lib/philomena_web/controllers/tag_controller.ex +++ b/lib/philomena_web/controllers/tag_controller.ex @@ -121,7 +121,7 @@ defmodule PhilomenaWeb.TagController do |> String.trim() |> String.downcase() - case Images.Query.compile(nil, name) do + case Images.Query.compile(name) do {:ok, %{term: %{"namespaced_tags.name" => ^name}}} -> name diff --git a/lib/philomena_web/image_loader.ex b/lib/philomena_web/image_loader.ex index d2bc80ea..81271e05 100644 --- a/lib/philomena_web/image_loader.ex +++ b/lib/philomena_web/image_loader.ex @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.ImageLoader do def search_string(conn, search_string, options \\ []) do user = conn.assigns.current_user - with {:ok, tree} <- Query.compile(user, search_string) do + with {:ok, tree} <- Query.compile(search_string, user: user) do {:ok, query(conn, tree, options)} else error -> diff --git a/lib/philomena_web/plugs/filter_forced_users_plug.ex b/lib/philomena_web/plugs/filter_forced_users_plug.ex index e28de969..d9881f96 100644 --- a/lib/philomena_web/plugs/filter_forced_users_plug.ex +++ b/lib/philomena_web/plugs/filter_forced_users_plug.ex @@ -6,7 +6,7 @@ defmodule PhilomenaWeb.FilterForcedUsersPlug do import Phoenix.Controller import Plug.Conn - alias PhilomenaQuery.Parse.String, as: SearchString + alias PhilomenaQuery.Parse.String alias PhilomenaQuery.Parse.Evaluator alias Philomena.Images.Query alias PhilomenaWeb.ImageView @@ -53,7 +53,10 @@ defmodule PhilomenaWeb.FilterForcedUsersPlug do end defp compile_filter(user, search_string) do - case Query.compile(user, SearchString.normalize(search_string)) do + search_string + |> String.normalize() + |> Query.compile(user: user) + |> case do {:ok, query} -> query _error -> %{match_all: %{}} end diff --git a/lib/philomena_web/plugs/image_filter_plug.ex b/lib/philomena_web/plugs/image_filter_plug.ex index c8138d68..a6d46187 100644 --- a/lib/philomena_web/plugs/image_filter_plug.ex +++ b/lib/philomena_web/plugs/image_filter_plug.ex @@ -1,7 +1,6 @@ defmodule PhilomenaWeb.ImageFilterPlug do import Plug.Conn - import PhilomenaQuery.Parse.String - + alias PhilomenaQuery.Parse.String alias Philomena.Images.Query # No options @@ -50,7 +49,10 @@ defmodule PhilomenaWeb.ImageFilterPlug do end defp invalid_filter_guard(user, search_string) do - case Query.compile(user, normalize(search_string)) do + search_string + |> String.normalize() + |> Query.compile(user: user) + |> case do {:ok, query} -> query _error -> %{match_all: %{}} end From 2dd2f43f372b006ce50408977cdd52c5331c01e9 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 13 Jul 2024 15:25:36 -0400 Subject: [PATCH 012/115] Perform coercion for query strings in parser module --- lib/philomena/comments/query.ex | 1 - lib/philomena/filters/query.ex | 1 - lib/philomena/galleries/query.ex | 2 -- lib/philomena/images/query.ex | 1 - lib/philomena/posts/query.ex | 1 - lib/philomena/reports/query.ex | 2 +- lib/philomena/tags/query.ex | 2 +- lib/philomena_query/parse/parser.ex | 19 +++++++++++++------ .../api/json/search/gallery_controller.ex | 2 +- .../api/json/search/tag_controller.ex | 2 +- 10 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/philomena/comments/query.ex b/lib/philomena/comments/query.ex index de2fee03..49ec6d68 100644 --- a/lib/philomena/comments/query.ex +++ b/lib/philomena/comments/query.ex @@ -94,7 +94,6 @@ defmodule Philomena.Comments.Query do def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) - query_string = query_string || "" case user do nil -> diff --git a/lib/philomena/filters/query.ex b/lib/philomena/filters/query.ex index 1741103c..3460a459 100644 --- a/lib/philomena/filters/query.ex +++ b/lib/philomena/filters/query.ex @@ -35,7 +35,6 @@ defmodule Philomena.Filters.Query do def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) - query_string = query_string || "" case user do nil -> diff --git a/lib/philomena/galleries/query.ex b/lib/philomena/galleries/query.ex index e04ceecc..ddfa1e8b 100644 --- a/lib/philomena/galleries/query.ex +++ b/lib/philomena/galleries/query.ex @@ -15,8 +15,6 @@ defmodule Philomena.Galleries.Query do end def compile(query_string) do - query_string = query_string || "" - fields() |> Parser.new() |> Parser.parse(query_string) diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex index 2c987d1b..74d1b835 100644 --- a/lib/philomena/images/query.ex +++ b/lib/philomena/images/query.ex @@ -147,7 +147,6 @@ defmodule Philomena.Images.Query do def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) watch = Keyword.get(opts, :watch, false) - query_string = query_string || "" case user do nil -> diff --git a/lib/philomena/posts/query.ex b/lib/philomena/posts/query.ex index 0722b10c..58d94d6c 100644 --- a/lib/philomena/posts/query.ex +++ b/lib/philomena/posts/query.ex @@ -92,7 +92,6 @@ defmodule Philomena.Posts.Query do def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) - query_string = query_string || "" case user do nil -> diff --git a/lib/philomena/reports/query.ex b/lib/philomena/reports/query.ex index c9d9be44..e88e2172 100644 --- a/lib/philomena/reports/query.ex +++ b/lib/philomena/reports/query.ex @@ -17,6 +17,6 @@ defmodule Philomena.Reports.Query do def compile(query_string) do fields() |> Parser.new() - |> Parser.parse(query_string || "", %{}) + |> Parser.parse(query_string, %{}) end end diff --git a/lib/philomena/tags/query.ex b/lib/philomena/tags/query.ex index da148da4..6af25454 100644 --- a/lib/philomena/tags/query.ex +++ b/lib/philomena/tags/query.ex @@ -20,6 +20,6 @@ defmodule Philomena.Tags.Query do def compile(query_string) do fields() |> Parser.new() - |> Parser.parse(query_string || "") + |> Parser.parse(query_string) end end diff --git a/lib/philomena_query/parse/parser.ex b/lib/philomena_query/parse/parser.ex index a89434d2..e615653f 100644 --- a/lib/philomena_query/parse/parser.ex +++ b/lib/philomena_query/parse/parser.ex @@ -184,18 +184,18 @@ defmodule PhilomenaQuery.Parse.Parser do @spec parse(t(), String.t(), context()) :: {:ok, query()} | {:error, String.t()} def parse(parser, input, context \\ nil) - # Empty search should emit a match_none. - def parse(_parser, "", _context) do - {:ok, %{match_none: %{}}} - end - def parse(%Parser{} = parser, input, context) do parser = %{parser | __data__: context} - with {:ok, tokens, _1, _2, _3, _4} <- Lexer.lex(input), + with {:ok, input} <- coerce_string(input), + {:ok, tokens, _1, _2, _3, _4} <- Lexer.lex(input), + {:ok, tokens} <- convert_empty_token_list(tokens), {:ok, {tree, []}} <- search_top(parser, tokens) do {:ok, tree} else + {:error, :empty_query} -> + {:ok, %{match_none: %{}}} + {:ok, {_tree, tokens}} -> {:error, "junk at end of expression: " <> debug_tokens(tokens)} @@ -211,6 +211,13 @@ defmodule PhilomenaQuery.Parse.Parser do end end + defp coerce_string(term) when is_binary(term), do: {:ok, term} + defp coerce_string(nil), do: {:ok, ""} + defp coerce_string(_), do: {:error, "search query is not a string"} + + defp convert_empty_token_list([]), do: {:error, :empty_query} + defp convert_empty_token_list(tokens), do: {:ok, tokens} + defp debug_tokens(tokens) do Enum.map_join(tokens, fn {_k, v} -> v end) end diff --git a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex index 8b2f247b..e1b999bb 100644 --- a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex @@ -7,7 +7,7 @@ defmodule PhilomenaWeb.Api.Json.Search.GalleryController do import Ecto.Query def index(conn, params) do - case Query.compile(params["q"] || "") do + case Query.compile(params["q"]) do {:ok, query} -> galleries = Gallery diff --git a/lib/philomena_web/controllers/api/json/search/tag_controller.ex b/lib/philomena_web/controllers/api/json/search/tag_controller.ex index 8cdaf7f4..b860cf46 100644 --- a/lib/philomena_web/controllers/api/json/search/tag_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/tag_controller.ex @@ -7,7 +7,7 @@ defmodule PhilomenaWeb.Api.Json.Search.TagController do import Ecto.Query def index(conn, params) do - case Query.compile(params["q"] || "") do + case Query.compile(params["q"]) do {:ok, query} -> tags = Tag From ade92481f8e9722b4b2c3589e749abebb6a4ff7f Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 9 Jul 2024 10:20:08 -0400 Subject: [PATCH 013/115] Remove Time schema, replace with custom type --- lib/philomena/adverts.ex | 4 +- lib/philomena/adverts/advert.ex | 21 ++---- lib/philomena/bans.ex | 12 ++-- lib/philomena/bans/fingerprint.ex | 14 +--- lib/philomena/bans/subnet.ex | 14 +--- lib/philomena/bans/user.ex | 35 +--------- lib/philomena/polls.ex | 4 +- lib/philomena/polls/poll.ex | 15 +---- lib/philomena/schema/time.ex | 23 ------- lib/philomena/site_notices.ex | 4 +- lib/philomena/site_notices/site_notice.ex | 23 ++----- lib/philomena/topics/topic.ex | 2 +- lib/philomena_query/ecto/relative_date.ex | 65 +++++++++++++++++++ lib/philomena_query/relative_date.ex | 20 ++++-- .../controllers/admin/user_ban_controller.ex | 20 ++++-- .../templates/admin/advert/_form.html.slime | 12 ++-- .../admin/fingerprint_ban/_form.html.slime | 6 +- .../admin/site_notice/_form.html.slime | 12 ++-- .../admin/subnet_ban/_form.html.slime | 6 +- .../templates/admin/user/index.html.slime | 2 +- .../templates/admin/user_ban/_form.html.slime | 10 ++- .../templates/admin/user_ban/edit.html.slime | 4 +- .../templates/admin/user_ban/index.html.slime | 4 -- .../templates/admin/user_ban/new.html.slime | 5 +- .../templates/profile/_admin_block.html.slime | 2 +- .../templates/topic/poll/_form.html.slime | 3 +- 26 files changed, 156 insertions(+), 186 deletions(-) delete mode 100644 lib/philomena/schema/time.ex create mode 100644 lib/philomena_query/ecto/relative_date.ex diff --git a/lib/philomena/adverts.ex b/lib/philomena/adverts.ex index f1794d8d..a6e4c31f 100644 --- a/lib/philomena/adverts.ex +++ b/lib/philomena/adverts.ex @@ -121,7 +121,7 @@ defmodule Philomena.Adverts do """ def create_advert(attrs \\ %{}) do %Advert{} - |> Advert.save_changeset(attrs) + |> Advert.changeset(attrs) |> Uploader.analyze_upload(attrs) |> Repo.insert() |> case do @@ -150,7 +150,7 @@ defmodule Philomena.Adverts do """ def update_advert(%Advert{} = advert, attrs) do advert - |> Advert.save_changeset(attrs) + |> Advert.changeset(attrs) |> Repo.update() end diff --git a/lib/philomena/adverts/advert.ex b/lib/philomena/adverts/advert.ex index 87e25379..7150f043 100644 --- a/lib/philomena/adverts/advert.ex +++ b/lib/philomena/adverts/advert.ex @@ -2,8 +2,6 @@ defmodule Philomena.Adverts.Advert do use Ecto.Schema import Ecto.Changeset - alias Philomena.Schema.Time - schema "adverts" do field :image, :string field :link, :string @@ -11,8 +9,8 @@ defmodule Philomena.Adverts.Advert do field :clicks, :integer, default: 0 field :impressions, :integer, default: 0 field :live, :boolean, default: false - field :start_date, :utc_datetime - field :finish_date, :utc_datetime + field :start_date, PhilomenaQuery.Ecto.RelativeDate + field :finish_date, PhilomenaQuery.Ecto.RelativeDate field :restrictions, :string field :notes, :string @@ -24,29 +22,18 @@ defmodule Philomena.Adverts.Advert do field :uploaded_image, :string, virtual: true field :removed_image, :string, virtual: true - field :start_time, :string, virtual: true - field :finish_time, :string, virtual: true - timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false def changeset(advert, attrs) do advert - |> cast(attrs, []) - |> Time.propagate_time(:start_date, :start_time) - |> Time.propagate_time(:finish_date, :finish_time) - end - - def save_changeset(advert, attrs) do - advert - |> cast(attrs, [:title, :link, :start_time, :finish_time, :live, :restrictions, :notes]) - |> Time.assign_time(:start_time, :start_date) - |> Time.assign_time(:finish_time, :finish_date) + |> cast(attrs, [:title, :link, :start_date, :finish_date, :live, :restrictions, :notes]) |> validate_required([:title, :link, :start_date, :finish_date]) |> validate_inclusion(:restrictions, ["none", "nsfw", "sfw"]) end + @doc false def image_changeset(advert, attrs) do advert |> cast(attrs, [ diff --git a/lib/philomena/bans.ex b/lib/philomena/bans.ex index 4b4bdcc8..50830e90 100644 --- a/lib/philomena/bans.ex +++ b/lib/philomena/bans.ex @@ -56,7 +56,7 @@ defmodule Philomena.Bans do """ def create_fingerprint(creator, attrs \\ %{}) do %Fingerprint{banning_user_id: creator.id} - |> Fingerprint.save_changeset(attrs) + |> Fingerprint.changeset(attrs) |> Repo.insert() end @@ -74,7 +74,7 @@ defmodule Philomena.Bans do """ def update_fingerprint(%Fingerprint{} = fingerprint, attrs) do fingerprint - |> Fingerprint.save_changeset(attrs) + |> Fingerprint.changeset(attrs) |> Repo.update() end @@ -150,7 +150,7 @@ defmodule Philomena.Bans do """ def create_subnet(creator, attrs \\ %{}) do %Subnet{banning_user_id: creator.id} - |> Subnet.save_changeset(attrs) + |> Subnet.changeset(attrs) |> Repo.insert() end @@ -168,7 +168,7 @@ defmodule Philomena.Bans do """ def update_subnet(%Subnet{} = subnet, attrs) do subnet - |> Subnet.save_changeset(attrs) + |> Subnet.changeset(attrs) |> Repo.update() end @@ -245,7 +245,7 @@ defmodule Philomena.Bans do def create_user(creator, attrs \\ %{}) do changeset = %User{banning_user_id: creator.id} - |> User.save_changeset(attrs) + |> User.changeset(attrs) Multi.new() |> Multi.insert(:user_ban, changeset) @@ -276,7 +276,7 @@ defmodule Philomena.Bans do """ def update_user(%User{} = user, attrs) do user - |> User.save_changeset(attrs) + |> User.changeset(attrs) |> Repo.update() end diff --git a/lib/philomena/bans/fingerprint.ex b/lib/philomena/bans/fingerprint.ex index 108fc024..5e61f8e7 100644 --- a/lib/philomena/bans/fingerprint.ex +++ b/lib/philomena/bans/fingerprint.ex @@ -3,7 +3,6 @@ defmodule Philomena.Bans.Fingerprint do import Ecto.Changeset alias Philomena.Users.User - alias Philomena.Schema.Time alias Philomena.Schema.BanId schema "fingerprint_bans" do @@ -12,26 +11,17 @@ defmodule Philomena.Bans.Fingerprint do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :utc_datetime + field :valid_until, PhilomenaQuery.Ecto.RelativeDate field :fingerprint, :string field :generated_ban_id, :string - field :until, :string, virtual: true - timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false def changeset(fingerprint_ban, attrs) do fingerprint_ban - |> cast(attrs, []) - |> Time.propagate_time(:valid_until, :until) - end - - def save_changeset(fingerprint_ban, attrs) do - fingerprint_ban - |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :until]) - |> Time.assign_time(:until, :valid_until) + |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :valid_until]) |> BanId.put_ban_id("F") |> validate_required([:reason, :enabled, :fingerprint, :valid_until]) |> check_constraint(:valid_until, name: :fingerprint_ban_duration_must_be_valid) diff --git a/lib/philomena/bans/subnet.ex b/lib/philomena/bans/subnet.ex index 1bd4ee00..e9b5bb95 100644 --- a/lib/philomena/bans/subnet.ex +++ b/lib/philomena/bans/subnet.ex @@ -3,7 +3,6 @@ defmodule Philomena.Bans.Subnet do import Ecto.Changeset alias Philomena.Users.User - alias Philomena.Schema.Time alias Philomena.Schema.BanId schema "subnet_bans" do @@ -12,26 +11,17 @@ defmodule Philomena.Bans.Subnet do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :utc_datetime + field :valid_until, PhilomenaQuery.Ecto.RelativeDate field :specification, EctoNetwork.INET field :generated_ban_id, :string - field :until, :string, virtual: true - timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false def changeset(subnet_ban, attrs) do subnet_ban - |> cast(attrs, []) - |> Time.propagate_time(:valid_until, :until) - end - - def save_changeset(subnet_ban, attrs) do - subnet_ban - |> cast(attrs, [:reason, :note, :enabled, :specification, :until]) - |> Time.assign_time(:until, :valid_until) + |> cast(attrs, [:reason, :note, :enabled, :specification, :valid_until]) |> BanId.put_ban_id("S") |> validate_required([:reason, :enabled, :specification, :valid_until]) |> check_constraint(:valid_until, name: :subnet_ban_duration_must_be_valid) diff --git a/lib/philomena/bans/user.ex b/lib/philomena/bans/user.ex index c2514191..1f142ea8 100644 --- a/lib/philomena/bans/user.ex +++ b/lib/philomena/bans/user.ex @@ -3,8 +3,6 @@ defmodule Philomena.Bans.User do import Ecto.Changeset alias Philomena.Users.User - alias Philomena.Repo - alias Philomena.Schema.Time alias Philomena.Schema.BanId schema "user_bans" do @@ -14,48 +12,19 @@ defmodule Philomena.Bans.User do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :utc_datetime + field :valid_until, PhilomenaQuery.Ecto.RelativeDate field :generated_ban_id, :string field :override_ip_ban, :boolean, default: false - field :username, :string, virtual: true - field :until, :string, virtual: true - timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false def changeset(user_ban, attrs) do user_ban - |> cast(attrs, []) - |> Time.propagate_time(:valid_until, :until) - |> populate_username() - end - - def save_changeset(user_ban, attrs) do - user_ban - |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :username, :until]) - |> Time.assign_time(:until, :valid_until) - |> populate_user_id() + |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :user_id, :valid_until]) |> BanId.put_ban_id("U") |> validate_required([:reason, :enabled, :user_id, :valid_until]) |> check_constraint(:valid_until, name: :user_ban_duration_must_be_valid) end - - defp populate_username(changeset) do - case maybe_get_by(:id, get_field(changeset, :user_id)) do - nil -> changeset - user -> put_change(changeset, :username, user.name) - end - end - - defp populate_user_id(changeset) do - case maybe_get_by(:name, get_field(changeset, :username)) do - nil -> changeset - %{id: id} -> put_change(changeset, :user_id, id) - end - end - - defp maybe_get_by(_field, nil), do: nil - defp maybe_get_by(field, value), do: Repo.get_by(User, [{field, value}]) end diff --git a/lib/philomena/polls.ex b/lib/philomena/polls.ex index 5dce91be..61cfaaa7 100644 --- a/lib/philomena/polls.ex +++ b/lib/philomena/polls.ex @@ -51,7 +51,7 @@ defmodule Philomena.Polls do """ def create_poll(attrs \\ %{}) do %Poll{} - |> Poll.update_changeset(attrs) + |> Poll.changeset(attrs) |> Repo.insert() end @@ -69,7 +69,7 @@ defmodule Philomena.Polls do """ def update_poll(%Poll{} = poll, attrs) do poll - |> Poll.update_changeset(attrs) + |> Poll.changeset(attrs) |> Repo.update() end diff --git a/lib/philomena/polls/poll.ex b/lib/philomena/polls/poll.ex index 919a62a1..b9032e7e 100644 --- a/lib/philomena/polls/poll.ex +++ b/lib/philomena/polls/poll.ex @@ -5,7 +5,6 @@ defmodule Philomena.Polls.Poll do alias Philomena.Topics.Topic alias Philomena.Users.User alias Philomena.PollOptions.PollOption - alias Philomena.Schema.Time schema "polls" do belongs_to :topic, Topic @@ -14,11 +13,10 @@ defmodule Philomena.Polls.Poll do field :title, :string field :vote_method, :string - field :active_until, :utc_datetime + field :active_until, PhilomenaQuery.Ecto.RelativeDate field :total_votes, :integer, default: 0 field :hidden_from_users, :boolean, default: false field :deletion_reason, :string, default: "" - field :until, :string, virtual: true timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -26,16 +24,7 @@ defmodule Philomena.Polls.Poll do @doc false def changeset(poll, attrs) do poll - |> cast(attrs, []) - |> validate_required([]) - |> Time.propagate_time(:active_until, :until) - end - - @doc false - def update_changeset(poll, attrs) do - poll - |> cast(attrs, [:title, :until, :vote_method]) - |> Time.assign_time(:until, :active_until) + |> cast(attrs, [:title, :active_until, :vote_method]) |> validate_required([:title, :active_until, :vote_method]) |> validate_length(:title, max: 140, count: :bytes) |> validate_inclusion(:vote_method, ["single", "multiple"]) diff --git a/lib/philomena/schema/time.ex b/lib/philomena/schema/time.ex deleted file mode 100644 index fff11419..00000000 --- a/lib/philomena/schema/time.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Philomena.Schema.Time do - alias PhilomenaQuery.RelativeDate - import Ecto.Changeset - - def assign_time(changeset, field, target_field) do - changeset - |> get_field(field) - |> RelativeDate.parse() - |> case do - {:ok, time} -> - put_change(changeset, target_field, time) - - _err -> - add_error(changeset, field, "is not a valid relative or absolute date and time") - end - end - - def propagate_time(changeset, field, target_field) do - time = get_field(changeset, field) - - put_change(changeset, target_field, to_string(time)) - end -end diff --git a/lib/philomena/site_notices.ex b/lib/philomena/site_notices.ex index b38f1fa4..a9042614 100644 --- a/lib/philomena/site_notices.ex +++ b/lib/philomena/site_notices.ex @@ -57,7 +57,7 @@ defmodule Philomena.SiteNotices do """ def create_site_notice(creator, attrs \\ %{}) do %SiteNotice{user_id: creator.id} - |> SiteNotice.save_changeset(attrs) + |> SiteNotice.changeset(attrs) |> Repo.insert() end @@ -75,7 +75,7 @@ defmodule Philomena.SiteNotices do """ def update_site_notice(%SiteNotice{} = site_notice, attrs) do site_notice - |> SiteNotice.save_changeset(attrs) + |> SiteNotice.changeset(attrs) |> Repo.update() end diff --git a/lib/philomena/site_notices/site_notice.ex b/lib/philomena/site_notices/site_notice.ex index 929f8f3c..fa76558a 100644 --- a/lib/philomena/site_notices/site_notice.ex +++ b/lib/philomena/site_notices/site_notice.ex @@ -3,21 +3,17 @@ defmodule Philomena.SiteNotices.SiteNotice do import Ecto.Changeset alias Philomena.Users.User - alias Philomena.Schema.Time schema "site_notices" do belongs_to :user, User field :title, :string - field :text, :string, default: "" + field :text, :string field :link, :string, default: "" field :link_text, :string, default: "" field :live, :boolean, default: true - field :start_date, :utc_datetime - field :finish_date, :utc_datetime - - field :start_time, :string, virtual: true - field :finish_time, :string, virtual: true + field :start_date, PhilomenaQuery.Ecto.RelativeDate + field :finish_date, PhilomenaQuery.Ecto.RelativeDate timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -25,16 +21,7 @@ defmodule Philomena.SiteNotices.SiteNotice do @doc false def changeset(site_notice, attrs) do site_notice - |> cast(attrs, []) - |> Time.propagate_time(:start_date, :start_time) - |> Time.propagate_time(:finish_date, :finish_time) - |> validate_required([]) - end - - def save_changeset(site_notice, attrs) do - site_notice - |> cast(attrs, [:title, :text, :link, :link_text, :live, :start_time, :finish_time]) - |> Time.assign_time(:start_time, :start_date) - |> Time.assign_time(:finish_time, :finish_date) + |> cast(attrs, [:title, :text, :link, :link_text, :live, :start_date, :finish_date]) + |> validate_required([:title, :text, :live, :start_date, :finish_date]) end end diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex index 7f2abbb6..d0e04c0b 100644 --- a/lib/philomena/topics/topic.ex +++ b/lib/philomena/topics/topic.ex @@ -58,7 +58,7 @@ defmodule Philomena.Topics.Topic do |> put_slug() |> change(forum: forum, user: attribution[:user]) |> validate_required(:forum) - |> cast_assoc(:poll, with: &Poll.update_changeset/2) + |> cast_assoc(:poll, with: &Poll.changeset/2) |> cast_assoc(:posts, with: &Post.topic_creation_changeset(&1, &2, attribution, anonymous?)) |> validate_length(:posts, is: 1) |> unique_constraint(:slug, name: :index_topics_on_forum_id_and_slug) diff --git a/lib/philomena_query/ecto/relative_date.ex b/lib/philomena_query/ecto/relative_date.ex new file mode 100644 index 00000000..2916dcb7 --- /dev/null +++ b/lib/philomena_query/ecto/relative_date.ex @@ -0,0 +1,65 @@ +defmodule PhilomenaQuery.Ecto.RelativeDate do + @moduledoc """ + Ecto custom type for relative dates. + + As a field type, it enables the following usage pattern: + + defmodule Notice do + use Ecto.Schema + import Ecto.Changeset + + schema "notices" do + field :start_date, PhilomenaQuery.Ecto.RelativeDate + field :finish_date, PhilomenaQuery.Ecto.RelativeDate + end + + @doc false + def changeset(notice, attrs) do + notice + |> cast(attrs, [:start_date, :finish_date]) + |> validate_required([:start_date, :finish_date]) + end + end + + """ + + use Ecto.Type + alias PhilomenaQuery.RelativeDate + + @doc false + def type do + :utc_datetime + end + + @doc false + def cast(input) + + def cast(input) when is_binary(input) do + case RelativeDate.parse(input) do + {:ok, result} -> + {:ok, result} + + _ -> + {:error, [message: "is not a valid relative or absolute date and time"]} + end + end + + def cast(%DateTime{} = input) do + {:ok, input} + end + + @doc false + def load(datetime) do + datetime = + datetime + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.truncate(:second) + + {:ok, datetime} + end + + @doc false + def dump(datetime) do + {:ok, datetime} + end +end diff --git a/lib/philomena_query/relative_date.ex b/lib/philomena_query/relative_date.ex index 7790629b..444bb4d0 100644 --- a/lib/philomena_query/relative_date.ex +++ b/lib/philomena_query/relative_date.ex @@ -42,12 +42,22 @@ defmodule PhilomenaQuery.RelativeDate do space = ignore(repeat(string(" "))) - moon = + permanent_specifier = + choice([ + string("moon"), + string("forever"), + string("permanent"), + string("permanently"), + string("indefinite"), + string("indefinitely") + ]) + + permanent = space - |> string("moon") + |> concat(permanent_specifier) |> concat(space) |> eos() - |> unwrap_and_tag(:moon) + |> unwrap_and_tag(:permanent) now = space @@ -69,7 +79,7 @@ defmodule PhilomenaQuery.RelativeDate do relative_date = choice([ - moon, + permanent, now, date ]) @@ -147,7 +157,7 @@ defmodule PhilomenaQuery.RelativeDate do now = DateTime.utc_now(:second) case relative_date(input) do - {:ok, [moon: _moon], _1, _2, _3, _4} -> + {:ok, [permanent: _permanent], _1, _2, _3, _4} -> {:ok, DateTime.add(now, 31_536_000_000, :second)} {:ok, [now: _now], _1, _2, _3, _4} -> diff --git a/lib/philomena_web/controllers/admin/user_ban_controller.ex b/lib/philomena_web/controllers/admin/user_ban_controller.ex index ff6833c0..f79fecd7 100644 --- a/lib/philomena_web/controllers/admin/user_ban_controller.ex +++ b/lib/philomena_web/controllers/admin/user_ban_controller.ex @@ -1,13 +1,14 @@ defmodule PhilomenaWeb.Admin.UserBanController do use PhilomenaWeb, :controller + alias Philomena.Users alias Philomena.Bans.User, as: UserBan alias Philomena.Bans alias Philomena.Repo import Ecto.Query plug :verify_authorized - plug :load_resource, model: UserBan, only: [:edit, :update, :delete] + plug :load_resource, model: UserBan, only: [:edit, :update, :delete], preload: :user plug :check_can_delete when action in [:delete] def index(conn, %{"q" => q}) when is_binary(q) do @@ -35,14 +36,21 @@ defmodule PhilomenaWeb.Admin.UserBanController do load_bans(UserBan, conn) end - def new(conn, %{"username" => username}) do - changeset = Bans.change_user(%UserBan{username: username}) - render(conn, "new.html", title: "New User Ban", changeset: changeset) + def new(conn, %{"user_id" => id}) do + target_user = Users.get_user!(id) + changeset = Bans.change_user(Ecto.build_assoc(target_user, :bans)) + + render(conn, "new.html", + title: "New User Ban", + target_user: target_user, + changeset: changeset + ) end def new(conn, _params) do - changeset = Bans.change_user(%UserBan{}) - render(conn, "new.html", title: "New User Ban", changeset: changeset) + conn + |> put_flash(:error, "Must create ban on user.") + |> redirect(to: ~p"/admin/user_bans") end def create(conn, %{"user" => user_ban_params}) do diff --git a/lib/philomena_web/templates/admin/advert/_form.html.slime b/lib/philomena_web/templates/admin/advert/_form.html.slime index 9fcd46c7..976f95d7 100644 --- a/lib/philomena_web/templates/admin/advert/_form.html.slime +++ b/lib/philomena_web/templates/admin/advert/_form.html.slime @@ -27,14 +27,14 @@ = error_tag f, :title .field - => label f, :start_time, "Start time for the advert (usually \"now\"):" - = text_input f, :start_time, class: "input input--wide", placeholder: "Start" - = error_tag f, :start_time + => label f, :start_date, "Start time for the advert (usually \"now\"):" + = text_input f, :start_date, class: "input input--wide", placeholder: "Start" + = error_tag f, :start_date .field - => label f, :finish_time, "Finish time for the advert (e.g. \"2 weeks from now\"):" - = text_input f, :finish_time, class: "input input--wide", placeholder: "Finish" - = error_tag f, :finish_time + => label f, :finish_date, "Finish time for the advert (e.g. \"2 weeks from now\"):" + = text_input f, :finish_date, class: "input input--wide", placeholder: "Finish" + = error_tag f, :finish_date .field => label f, :notes, "Notes (Payment details, contact info, etc):" diff --git a/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime b/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime index e81456ee..9fc35560 100644 --- a/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime +++ b/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime @@ -17,9 +17,9 @@ = text_input f, :note, class: "input input--wide", placeholder: "Note" .field - => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" - = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true - = error_tag f, :until + => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" + = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true + = error_tag f, :valid_until br .field diff --git a/lib/philomena_web/templates/admin/site_notice/_form.html.slime b/lib/philomena_web/templates/admin/site_notice/_form.html.slime index 2e614917..8f507ef2 100644 --- a/lib/philomena_web/templates/admin/site_notice/_form.html.slime +++ b/lib/philomena_web/templates/admin/site_notice/_form.html.slime @@ -30,14 +30,14 @@ h3 Run Time .field - => label f, :start_time, "Start time for the site notice (usually \"now\"):" - = text_input f, :start_time, class: "input input--wide", required: true - = error_tag f, :start_time + => label f, :start_date, "Start time for the site notice (usually \"now\"):" + = text_input f, :start_date, class: "input input--wide", required: true + = error_tag f, :start_date .field - => label f, :finish_time, "Finish time for the site notice (e.g. \"2 weeks from now\"):" - = text_input f, :finish_time, class: "input input--wide", required: true - = error_tag f, :finish_time + => label f, :finish_date, "Finish time for the site notice (e.g. \"2 weeks from now\"):" + = text_input f, :finish_date, class: "input input--wide", required: true + = error_tag f, :finish_date h3 Enable .field diff --git a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime index bf87b2ce..a6ca83b2 100644 --- a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime +++ b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime @@ -17,9 +17,9 @@ = text_input f, :note, class: "input input--wide", placeholder: "Note" .field - => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" - = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true - = error_tag f, :until + => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" + = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true + = error_tag f, :valid_until br .field diff --git a/lib/philomena_web/templates/admin/user/index.html.slime b/lib/philomena_web/templates/admin/user/index.html.slime index 7157e7d9..e3077f94 100644 --- a/lib/philomena_web/templates/admin/user/index.html.slime +++ b/lib/philomena_web/templates/admin/user/index.html.slime @@ -82,7 +82,7 @@ h1 Users /' • = if can?(@conn, :index, Philomena.Bans.User) do - => link to: ~p"/admin/user_bans/new?#{[username: user.name]}" do + => link to: ~p"/admin/user_bans/new?#{[user_id: user.id]}" do i.fa.fa-fw.fa-ban ' Ban = if can?(@conn, :edit, Philomena.ArtistLinks.ArtistLink) do diff --git a/lib/philomena_web/templates/admin/user_ban/_form.html.slime b/lib/philomena_web/templates/admin/user_ban/_form.html.slime index 292f07eb..812bfc61 100644 --- a/lib/philomena_web/templates/admin/user_ban/_form.html.slime +++ b/lib/philomena_web/templates/admin/user_ban/_form.html.slime @@ -3,9 +3,7 @@ .alert.alert-danger p Oops, something went wrong! Please check the errors below. - .field - => label f, :username, "Username:" - = text_input f, :username, class: "input", placeholder: "Username", required: true + = hidden_input f, :user_id .field => label f, :reason, "Reason (shown to the banned user, and to staff on the user's profile page):" @@ -17,9 +15,9 @@ = text_input f, :note, class: "input input--wide", placeholder: "Note" .field - => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" - = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true - = error_tag f, :until + => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" + = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true + = error_tag f, :valid_until br .field diff --git a/lib/philomena_web/templates/admin/user_ban/edit.html.slime b/lib/philomena_web/templates/admin/user_ban/edit.html.slime index 604f0ed6..9d3349a9 100644 --- a/lib/philomena_web/templates/admin/user_ban/edit.html.slime +++ b/lib/philomena_web/templates/admin/user_ban/edit.html.slime @@ -1,4 +1,6 @@ -h1 Editing ban +h1 + ' Editing user ban for user + = @user.user.name = render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: ~p"/admin/user_bans/#{@user}", conn: @conn diff --git a/lib/philomena_web/templates/admin/user_ban/index.html.slime b/lib/philomena_web/templates/admin/user_ban/index.html.slime index 5b60fc21..c6a35ac4 100644 --- a/lib/philomena_web/templates/admin/user_ban/index.html.slime +++ b/lib/philomena_web/templates/admin/user_ban/index.html.slime @@ -10,10 +10,6 @@ h1 User Bans .block .block__header - a href=~p"/admin/user_bans/new" - i.fa.fa-plus> - ' New user ban - = pagination .block__content diff --git a/lib/philomena_web/templates/admin/user_ban/new.html.slime b/lib/philomena_web/templates/admin/user_ban/new.html.slime index cdd82ff8..55293ac5 100644 --- a/lib/philomena_web/templates/admin/user_ban/new.html.slime +++ b/lib/philomena_web/templates/admin/user_ban/new.html.slime @@ -1,4 +1,7 @@ -h1 New User Ban +h1 + ' New User Ban for user + = @target_user.name + = render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: ~p"/admin/user_bans", conn: @conn br diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime index ca2462ab..4f827788 100644 --- a/lib/philomena_web/templates/profile/_admin_block.html.slime +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -138,7 +138,7 @@ a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__optio = if can?(@conn, :create, Philomena.Bans.User) do li - = link to: ~p"/admin/user_bans/new?#{[username: @user.name]}" do + = link to: ~p"/admin/user_bans/new?#{[user_id: @user.id]}" do i.fa.fa-fw.fa-ban span.admin__button Ban this sucker diff --git a/lib/philomena_web/templates/topic/poll/_form.html.slime b/lib/philomena_web/templates/topic/poll/_form.html.slime index f5d960b0..682d3c43 100644 --- a/lib/philomena_web/templates/topic/poll/_form.html.slime +++ b/lib/philomena_web/templates/topic/poll/_form.html.slime @@ -12,8 +12,7 @@ p.fieldlabel ' End date .field.field--block - = text_input @f, :until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255 - = error_tag @f, :until + = text_input @f, :active_until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255 = error_tag @f, :active_until p.fieldlabel From c89424d1464d5a7a6645914e3fc26d783a8be182 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 9 Jul 2024 13:26:26 -0400 Subject: [PATCH 014/115] Extract query validation callback step to custom validator --- lib/philomena/filters/filter.ex | 7 ++- lib/philomena/schema/search.ex | 18 ------ lib/philomena/users/user.ex | 7 ++- lib/philomena_query/ecto/query_validator.ex | 69 +++++++++++++++++++++ 4 files changed, 77 insertions(+), 24 deletions(-) delete mode 100644 lib/philomena/schema/search.ex create mode 100644 lib/philomena_query/ecto/query_validator.ex diff --git a/lib/philomena/filters/filter.ex b/lib/philomena/filters/filter.ex index ef1222d7..29d542f7 100644 --- a/lib/philomena/filters/filter.ex +++ b/lib/philomena/filters/filter.ex @@ -1,9 +1,10 @@ defmodule Philomena.Filters.Filter do use Ecto.Schema import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator alias Philomena.Schema.TagList - alias Philomena.Schema.Search + alias Philomena.Images.Query alias Philomena.Users.User alias Philomena.Repo @@ -48,8 +49,8 @@ defmodule Philomena.Filters.Filter do |> validate_required([:name]) |> validate_my_downvotes(:spoilered_complex_str) |> validate_my_downvotes(:hidden_complex_str) - |> Search.validate_search(:spoilered_complex_str, user) - |> Search.validate_search(:hidden_complex_str, user) + |> validate_query(:spoilered_complex_str, &Query.compile(&1, user: user)) + |> validate_query(:hidden_complex_str, &Query.compile(&1, user: user)) |> unsafe_validate_unique([:user_id, :name], Repo) end diff --git a/lib/philomena/schema/search.ex b/lib/philomena/schema/search.ex deleted file mode 100644 index 819823f2..00000000 --- a/lib/philomena/schema/search.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Philomena.Schema.Search do - alias Philomena.Images.Query - alias PhilomenaQuery.Parse.String - import Ecto.Changeset - - def validate_search(changeset, field, user, watched \\ false) do - query = changeset |> get_field(field) |> String.normalize() - output = Query.compile(query, user: user, watch: watched) - - case output do - {:ok, _} -> - changeset - - _ -> - add_error(changeset, field, "is invalid") - end - end -end diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 28018915..c005d92e 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -4,10 +4,11 @@ defmodule Philomena.Users.User do use Ecto.Schema import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator alias Philomena.Schema.TagList - alias Philomena.Schema.Search + alias Philomena.Images.Query alias Philomena.Filters.Filter alias Philomena.ArtistLinks.ArtistLink alias Philomena.Badges @@ -354,8 +355,8 @@ defmodule Philomena.Users.User do |> validate_inclusion(:images_per_page, 1..50) |> validate_inclusion(:comments_per_page, 1..100) |> validate_inclusion(:scale_large_images, ["false", "partscaled", "true"]) - |> Search.validate_search(:watched_images_query_str, user, true) - |> Search.validate_search(:watched_images_exclude_str, user, true) + |> validate_query(:watched_images_query_str, &Query.compile(&1, user: user, watch: true)) + |> validate_query(:watched_images_exclude_str, &Query.compile(&1, user: user, watch: true)) end def description_changeset(user, attrs) do diff --git a/lib/philomena_query/ecto/query_validator.ex b/lib/philomena_query/ecto/query_validator.ex new file mode 100644 index 00000000..ea0b8950 --- /dev/null +++ b/lib/philomena_query/ecto/query_validator.ex @@ -0,0 +1,69 @@ +defmodule PhilomenaQuery.Ecto.QueryValidator do + @moduledoc """ + Query string validation for Ecto. + + It enables the following usage pattern by taking a fn of the compiler: + + defmodule Filter do + import PhilomenaQuery.Ecto.QueryValidator + + # ... + + def changeset(filter, attrs, user) do + filter + |> cast(attrs, [:complex]) + |> validate_required([:complex]) + |> validate_query([:complex], with: &Query.compile(&1, user: user)) + end + end + + """ + + import Ecto.Changeset + alias PhilomenaQuery.Parse.String + + @doc """ + Validates a query string using the provided attribute(s) and compiler. + + Returns the changeset as-is, or with an `"is invalid"` error added to validated field. + + ## Examples + + # With single attribute + filter + |> cast(attrs, [:complex]) + |> validate_query(:complex, &Query.compile(&1, user: user)) + + # With list of attributes + filter + |> cast(attrs, [:spoilered_complex, :hidden_complex]) + |> validate_query([:spoilered_complex, :hidden_complex], &Query.compile(&1, user: user)) + + """ + def validate_query(changeset, attr_or_attr_list, callback) + + def validate_query(changeset, attr_list, callback) when is_list(attr_list) do + Enum.reduce(attr_list, changeset, fn attr, changeset -> + validate_query(changeset, attr, callback) + end) + end + + def validate_query(changeset, attr, callback) do + if changed?(changeset, attr) do + validate_assuming_changed(changeset, attr, callback) + else + changeset + end + end + + defp validate_assuming_changed(changeset, attr, callback) do + with value when is_binary(value) <- fetch_change!(changeset, attr) || "", + value <- String.normalize(value), + {:ok, _} <- callback.(value) do + changeset + else + _ -> + add_error(changeset, attr, "is invalid") + end + end +end From 4493dfcc1d43842283d36d0cc1f31877e6ff8c83 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 9 Jul 2024 19:48:24 -0400 Subject: [PATCH 015/115] Move ban ID generator to bans namespace --- lib/philomena/bans/fingerprint.ex | 4 ++-- lib/philomena/{schema/ban_id.ex => bans/id_generator.ex} | 4 +++- lib/philomena/bans/subnet.ex | 4 ++-- lib/philomena/bans/user.ex | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) rename lib/philomena/{schema/ban_id.ex => bans/id_generator.ex} (82%) diff --git a/lib/philomena/bans/fingerprint.ex b/lib/philomena/bans/fingerprint.ex index 5e61f8e7..5b499554 100644 --- a/lib/philomena/bans/fingerprint.ex +++ b/lib/philomena/bans/fingerprint.ex @@ -1,9 +1,9 @@ defmodule Philomena.Bans.Fingerprint do use Ecto.Schema import Ecto.Changeset + import Philomena.Bans.IdGenerator alias Philomena.Users.User - alias Philomena.Schema.BanId schema "fingerprint_bans" do belongs_to :banning_user, User @@ -22,7 +22,7 @@ defmodule Philomena.Bans.Fingerprint do def changeset(fingerprint_ban, attrs) do fingerprint_ban |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :valid_until]) - |> BanId.put_ban_id("F") + |> put_ban_id("F") |> validate_required([:reason, :enabled, :fingerprint, :valid_until]) |> check_constraint(:valid_until, name: :fingerprint_ban_duration_must_be_valid) end diff --git a/lib/philomena/schema/ban_id.ex b/lib/philomena/bans/id_generator.ex similarity index 82% rename from lib/philomena/schema/ban_id.ex rename to lib/philomena/bans/id_generator.ex index c1c8ee02..e2b7cf03 100644 --- a/lib/philomena/schema/ban_id.ex +++ b/lib/philomena/bans/id_generator.ex @@ -1,4 +1,6 @@ -defmodule Philomena.Schema.BanId do +defmodule Philomena.Bans.IdGenerator do + @moduledoc false + import Ecto.Changeset def put_ban_id(%{data: %{generated_ban_id: nil}} = changeset, prefix) do diff --git a/lib/philomena/bans/subnet.ex b/lib/philomena/bans/subnet.ex index e9b5bb95..2eeb424a 100644 --- a/lib/philomena/bans/subnet.ex +++ b/lib/philomena/bans/subnet.ex @@ -1,9 +1,9 @@ defmodule Philomena.Bans.Subnet do use Ecto.Schema import Ecto.Changeset + import Philomena.Bans.IdGenerator alias Philomena.Users.User - alias Philomena.Schema.BanId schema "subnet_bans" do belongs_to :banning_user, User @@ -22,7 +22,7 @@ defmodule Philomena.Bans.Subnet do def changeset(subnet_ban, attrs) do subnet_ban |> cast(attrs, [:reason, :note, :enabled, :specification, :valid_until]) - |> BanId.put_ban_id("S") + |> put_ban_id("S") |> validate_required([:reason, :enabled, :specification, :valid_until]) |> check_constraint(:valid_until, name: :subnet_ban_duration_must_be_valid) |> mask_specification() diff --git a/lib/philomena/bans/user.ex b/lib/philomena/bans/user.ex index 1f142ea8..efae6214 100644 --- a/lib/philomena/bans/user.ex +++ b/lib/philomena/bans/user.ex @@ -1,9 +1,9 @@ defmodule Philomena.Bans.User do use Ecto.Schema import Ecto.Changeset + import Philomena.Bans.IdGenerator alias Philomena.Users.User - alias Philomena.Schema.BanId schema "user_bans" do belongs_to :user, User @@ -23,7 +23,7 @@ defmodule Philomena.Bans.User do def changeset(user_ban, attrs) do user_ban |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :user_id, :valid_until]) - |> BanId.put_ban_id("U") + |> put_ban_id("U") |> validate_required([:reason, :enabled, :user_id, :valid_until]) |> check_constraint(:valid_until, name: :user_ban_duration_must_be_valid) end From cd2efb0d39b24c9e329963aa73dfb62617d5ee54 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 14 Jul 2024 14:48:11 -0400 Subject: [PATCH 016/115] Remove Repo fetch from Channel module --- lib/philomena/channels.ex | 21 +++++++++++++++++++++ lib/philomena/channels/channel.ex | 16 ++++++++-------- lib/philomena/tags.ex | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index ea07f0d5..0f00cbe9 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -8,6 +8,7 @@ defmodule Philomena.Channels do alias Philomena.Channels.AutomaticUpdater alias Philomena.Channels.Channel + alias Philomena.Tags use Philomena.Subscriptions, actor_types: ~w(Channel LivestreamChannel), @@ -50,6 +51,7 @@ defmodule Philomena.Channels do """ def create_channel(attrs \\ %{}) do %Channel{} + |> update_artist_tag(attrs) |> Channel.changeset(attrs) |> Repo.insert() end @@ -68,10 +70,29 @@ defmodule Philomena.Channels do """ def update_channel(%Channel{} = channel, attrs) do channel + |> update_artist_tag(attrs) |> Channel.changeset(attrs) |> Repo.update() end + @doc """ + Adds the artist tag from the `"artist_tag"` tag name attribute. + + ## Examples + + iex> update_artist_tag(%Channel{}, %{"artist_tag" => "artist:nighty"}) + %Ecto.Changeset{} + + """ + def update_artist_tag(%Channel{} = channel, attrs) do + tag = + attrs + |> Map.get("artist_tag", "") + |> Tags.get_tag_by_name() + + Channel.artist_tag_changeset(channel, tag) + end + @doc """ Updates a channel's state when it goes live. diff --git a/lib/philomena/channels/channel.ex b/lib/philomena/channels/channel.ex index 3af5a8b1..54bf30ba 100644 --- a/lib/philomena/channels/channel.ex +++ b/lib/philomena/channels/channel.ex @@ -3,7 +3,6 @@ defmodule Philomena.Channels.Channel do import Ecto.Changeset alias Philomena.Tags.Tag - alias Philomena.Repo schema "channels" do belongs_to :associated_artist_tag, Tag @@ -36,19 +35,13 @@ defmodule Philomena.Channels.Channel do @doc false def changeset(channel, attrs) do - tag_id = - case Repo.get_by(Tag, name: attrs["artist_tag"] || "") do - %{id: id} -> id - _ -> nil - end - channel |> cast(attrs, [:type, :short_name]) |> validate_required([:type, :short_name]) |> validate_inclusion(:type, ["PicartoChannel", "PiczelChannel"]) - |> put_change(:associated_artist_tag_id, tag_id) end + @doc false def update_changeset(channel, attrs) do cast(channel, attrs, [ :title, @@ -60,4 +53,11 @@ defmodule Philomena.Channels.Channel do :last_live_at ]) end + + @doc false + def artist_tag_changeset(channel, tag) do + tag_id = Map.get(tag || %{}, :id) + + change(channel, associated_artist_tag_id: tag_id) + end end diff --git a/lib/philomena/tags.ex b/lib/philomena/tags.ex index 0d759e93..d6c6898a 100644 --- a/lib/philomena/tags.ex +++ b/lib/philomena/tags.ex @@ -81,6 +81,22 @@ defmodule Philomena.Tags do """ def get_tag!(id), do: Repo.get!(Tag, id) + @doc """ + Gets a single tag. + + Returns nil if the Tag does not exist. + + ## Examples + + iex> get_tag_by_name("safe") + %Tag{} + + iex> get_tag_by_name("nonexistent") + nil + + """ + def get_tag_by_name(name), do: Repo.get_by(Tag, name: name) + @doc """ Gets a single tag by its name, or the tag it is aliased to, if it is aliased. From dd5a118d8cfc035c00b95312a5dce28e8991b3ea Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Jul 2024 20:43:45 -0400 Subject: [PATCH 017/115] Simplify moderation logs context --- lib/philomena/moderation_logs.ex | 41 ++++--------------- .../controllers/moderation_log_controller.ex | 2 +- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/lib/philomena/moderation_logs.ex b/lib/philomena/moderation_logs.ex index dcdb53f4..b84d37ad 100644 --- a/lib/philomena/moderation_logs.ex +++ b/lib/philomena/moderation_logs.ex @@ -9,40 +9,24 @@ defmodule Philomena.ModerationLogs do alias Philomena.ModerationLogs.ModerationLog @doc """ - Returns the list of moderation_logs. + Returns a paginated list of moderation logs as a `m:Scrivener.Page`. ## Examples - iex> list_moderation_logs() + iex> list_moderation_logs(page_size: 15) [%ModerationLog{}, ...] """ - def list_moderation_logs(conn) do + def list_moderation_logs(pagination) do ModerationLog - |> where([ml], ml.created_at > ago(2, "week")) + |> where([ml], ml.created_at >= ago(2, "week")) |> preload(:user) |> order_by(desc: :created_at) - |> Repo.paginate(conn.assigns.scrivener) + |> Repo.paginate(pagination) end @doc """ - Gets a single moderation_log. - - Raises `Ecto.NoResultsError` if the Moderation log does not exist. - - ## Examples - - iex> get_moderation_log!(123) - %ModerationLog{} - - iex> get_moderation_log!(456) - ** (Ecto.NoResultsError) - - """ - def get_moderation_log!(id), do: Repo.get!(ModerationLog, id) - - @doc """ - Creates a moderation_log. + Creates a moderation log. ## Examples @@ -60,21 +44,14 @@ defmodule Philomena.ModerationLogs do end @doc """ - Deletes a moderation_log. + Removes moderation logs created more than 2 weeks ago. ## Examples - iex> delete_moderation_log(moderation_log) - {:ok, %ModerationLog{}} - - iex> delete_moderation_log(moderation_log) - {:error, %Ecto.Changeset{}} + iex> cleanup!() + {31, nil} """ - def delete_moderation_log(%ModerationLog{} = moderation_log) do - Repo.delete(moderation_log) - end - def cleanup! do ModerationLog |> where([ml], ml.created_at < ago(2, "week")) diff --git a/lib/philomena_web/controllers/moderation_log_controller.ex b/lib/philomena_web/controllers/moderation_log_controller.ex index 3d9e7699..a3f202b1 100644 --- a/lib/philomena_web/controllers/moderation_log_controller.ex +++ b/lib/philomena_web/controllers/moderation_log_controller.ex @@ -9,7 +9,7 @@ defmodule PhilomenaWeb.ModerationLogController do preload: [:user] def index(conn, _params) do - moderation_logs = ModerationLogs.list_moderation_logs(conn) + moderation_logs = ModerationLogs.list_moderation_logs(conn.assigns.scrivener) render(conn, "index.html", title: "Moderation Logs", moderation_logs: moderation_logs) end end From 93a6d8d1173e557514d623eb0b426974225a778b Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Jul 2024 20:57:39 -0400 Subject: [PATCH 018/115] Move mod note query string query to context --- lib/philomena/mod_notes.ex | 48 +++++++++++++++++-- .../controllers/admin/mod_note_controller.ex | 28 ++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/lib/philomena/mod_notes.ex b/lib/philomena/mod_notes.ex index 9ae9313b..4c75e3bc 100644 --- a/lib/philomena/mod_notes.ex +++ b/lib/philomena/mod_notes.ex @@ -7,18 +7,56 @@ defmodule Philomena.ModNotes do alias Philomena.Repo alias Philomena.ModNotes.ModNote + alias Philomena.Polymorphic @doc """ - Returns the list of mod_notes. + Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output + for the query string current pagination. + + All mod notes containing the substring `query_string` are matched and returned + case-insensitively. + + See `list_mod_notes/3` for more information. ## Examples - iex> list_mod_notes() - [%ModNote{}, ...] + iex> list_mod_notes_by_query_string("quack", & &1.body, page_size: 15) + %Scrivener.Page{} """ - def list_mod_notes do - Repo.all(ModNote) + def list_mod_notes_by_query_string(query_string, collection_renderer, pagination) do + ModNote + |> where([m], ilike(m.body, ^"%#{query_string}%")) + |> list_mod_notes(collection_renderer, pagination) + end + + @doc """ + Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output + for the current pagination. + + When coerced to a list and rendered as Markdown, the result may look like: + + [ + {%ModNote{body: "hello *world*"}, "hello world"} + ] + + ## Examples + + iex> list_mod_notes(& &1.body, page_size: 15) + %Scrivener.Page{} + + """ + def list_mod_notes(queryable \\ ModNote, collection_renderer, pagination) do + mod_notes = + queryable + |> preload(:moderator) + |> order_by(desc: :id) + |> Repo.paginate(pagination) + + bodies = collection_renderer.(mod_notes) + preloaded = Polymorphic.load_polymorphic(mod_notes, notable: [notable_id: :notable_type]) + + put_in(mod_notes.entries, Enum.zip(bodies, preloaded)) end @doc """ diff --git a/lib/philomena_web/controllers/admin/mod_note_controller.ex b/lib/philomena_web/controllers/admin/mod_note_controller.ex index f5b3999f..604e0140 100644 --- a/lib/philomena_web/controllers/admin/mod_note_controller.ex +++ b/lib/philomena_web/controllers/admin/mod_note_controller.ex @@ -5,33 +5,23 @@ defmodule PhilomenaWeb.Admin.ModNoteController do alias Philomena.ModNotes.ModNote alias Philomena.Polymorphic alias Philomena.ModNotes - alias Philomena.Repo - import Ecto.Query plug :verify_authorized plug :load_resource, model: ModNote, only: [:edit, :update, :delete] plug :preload_association when action in [:edit, :update, :delete] - def index(conn, %{"q" => q}) do - ModNote - |> where([m], ilike(m.body, ^"%#{q}%")) - |> load_mod_notes(conn) - end + def index(conn, params) do + pagination = conn.assigns.scrivener + renderer = &MarkdownRenderer.render_collection(&1, conn) - def index(conn, _params) do - load_mod_notes(ModNote, conn) - end - - defp load_mod_notes(queryable, conn) do mod_notes = - queryable - |> preload(:moderator) - |> order_by(desc: :id) - |> Repo.paginate(conn.assigns.scrivener) + case params do + %{"q" => q} -> + ModNotes.list_mod_notes_by_query_string(q, renderer, pagination) - bodies = MarkdownRenderer.render_collection(mod_notes, conn) - preloaded = Polymorphic.load_polymorphic(mod_notes, notable: [notable_id: :notable_type]) - mod_notes = %{mod_notes | entries: Enum.zip(bodies, preloaded)} + _ -> + ModNotes.list_mod_notes(renderer, pagination) + end render(conn, "index.html", title: "Admin - Mod Notes", mod_notes: mod_notes) end From f3805d55fec25f694557b3c6f00ac0e463004b81 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Jul 2024 21:19:32 -0400 Subject: [PATCH 019/115] Simplify mod note lookup in controllers --- lib/philomena/mod_notes.ex | 36 ++++++++++++++++--- .../controllers/admin/report_controller.ex | 17 ++------- .../controllers/dnp_entry_controller.ex | 18 ++-------- .../controllers/profile/detail_controller.ex | 17 ++------- .../controllers/profile_controller.ex | 18 ++-------- .../admin/mod_note/_table.html.slime | 2 +- .../templates/profile/show.html.slime | 2 +- 7 files changed, 45 insertions(+), 65 deletions(-) diff --git a/lib/philomena/mod_notes.ex b/lib/philomena/mod_notes.ex index 4c75e3bc..450216b0 100644 --- a/lib/philomena/mod_notes.ex +++ b/lib/philomena/mod_notes.ex @@ -10,8 +10,30 @@ defmodule Philomena.ModNotes do alias Philomena.Polymorphic @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output - for the query string current pagination. + Returns a list of 2-tuples of mod notes and rendered output for the notable type and id. + + See `list_mod_notes/3` for more information about collection rendering. + + ## Examples + + iex> list_all_mod_notes_by_type_and_id("User", "1", & &1.body) + [ + {%ModNote{body: "hello *world*"}, "hello *world*"} + ] + + """ + def list_all_mod_notes_by_type_and_id(notable_type, notable_id, collection_renderer) do + ModNote + |> where(notable_type: ^notable_type, notable_id: ^notable_id) + |> preload(:moderator) + |> order_by(desc: :id) + |> Repo.all() + |> preload_and_render(collection_renderer) + end + + @doc """ + Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output + for the query string and current pagination. All mod notes containing the substring `query_string` are matched and returned case-insensitively. @@ -31,13 +53,13 @@ defmodule Philomena.ModNotes do end @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output + Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output for the current pagination. When coerced to a list and rendered as Markdown, the result may look like: [ - {%ModNote{body: "hello *world*"}, "hello world"} + {%ModNote{body: "hello *world*"}, "hello world"} ] ## Examples @@ -53,10 +75,14 @@ defmodule Philomena.ModNotes do |> order_by(desc: :id) |> Repo.paginate(pagination) + put_in(mod_notes.entries, preload_and_render(mod_notes, collection_renderer)) + end + + defp preload_and_render(mod_notes, collection_renderer) do bodies = collection_renderer.(mod_notes) preloaded = Polymorphic.load_polymorphic(mod_notes, notable: [notable_id: :notable_type]) - put_in(mod_notes.entries, Enum.zip(bodies, preloaded)) + Enum.zip(preloaded, bodies) end @doc """ diff --git a/lib/philomena_web/controllers/admin/report_controller.ex b/lib/philomena_web/controllers/admin/report_controller.ex index e6fc6a97..3dc773ea 100644 --- a/lib/philomena_web/controllers/admin/report_controller.ex +++ b/lib/philomena_web/controllers/admin/report_controller.ex @@ -6,7 +6,7 @@ defmodule PhilomenaWeb.Admin.ReportController do alias Philomena.Reports.Report alias Philomena.Reports.Query alias Philomena.Polymorphic - alias Philomena.ModNotes.ModNote + alias Philomena.ModNotes alias Philomena.Repo import Ecto.Query @@ -128,19 +128,8 @@ defmodule PhilomenaWeb.Admin.ReportController do true -> report = conn.assigns.report - mod_notes = - ModNote - |> where(notable_type: "Report", notable_id: ^report.id) - |> order_by(desc: :id) - |> preload(:moderator) - |> Repo.all() - |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type]) - - mod_notes = - mod_notes - |> MarkdownRenderer.render_collection(conn) - |> Enum.zip(mod_notes) - + renderer = &MarkdownRenderer.render_collection(&1, conn) + mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("Report", report.id, renderer) assign(conn, :mod_notes, mod_notes) _false -> diff --git a/lib/philomena_web/controllers/dnp_entry_controller.ex b/lib/philomena_web/controllers/dnp_entry_controller.ex index d2e675b2..a71407ac 100644 --- a/lib/philomena_web/controllers/dnp_entry_controller.ex +++ b/lib/philomena_web/controllers/dnp_entry_controller.ex @@ -5,8 +5,7 @@ defmodule PhilomenaWeb.DnpEntryController do alias PhilomenaWeb.MarkdownRenderer alias Philomena.DnpEntries alias Philomena.Tags.Tag - alias Philomena.ModNotes.ModNote - alias Philomena.Polymorphic + alias Philomena.ModNotes alias Philomena.Repo import Ecto.Query @@ -154,19 +153,8 @@ defmodule PhilomenaWeb.DnpEntryController do true -> dnp_entry = conn.assigns.dnp_entry - mod_notes = - ModNote - |> where(notable_type: "DnpEntry", notable_id: ^dnp_entry.id) - |> order_by(desc: :id) - |> preload(:moderator) - |> Repo.all() - |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type]) - - mod_notes = - mod_notes - |> MarkdownRenderer.render_collection(conn) - |> Enum.zip(mod_notes) - + renderer = &MarkdownRenderer.render_collection(&1, conn) + mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("DnpEntry", dnp_entry.id, renderer) assign(conn, :mod_notes, mod_notes) _false -> diff --git a/lib/philomena_web/controllers/profile/detail_controller.ex b/lib/philomena_web/controllers/profile/detail_controller.ex index 0461c31e..c891b6ca 100644 --- a/lib/philomena_web/controllers/profile/detail_controller.ex +++ b/lib/philomena_web/controllers/profile/detail_controller.ex @@ -2,9 +2,8 @@ defmodule PhilomenaWeb.Profile.DetailController do use PhilomenaWeb, :controller alias Philomena.UserNameChanges.UserNameChange - alias Philomena.ModNotes.ModNote + alias Philomena.ModNotes alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Polymorphic alias Philomena.Users.User alias Philomena.Repo import Ecto.Query @@ -20,18 +19,8 @@ defmodule PhilomenaWeb.Profile.DetailController do def index(conn, _params) do user = conn.assigns.user - mod_notes = - ModNote - |> where(notable_type: "User", notable_id: ^user.id) - |> order_by(desc: :id) - |> preload(:moderator) - |> Repo.all() - |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type]) - - mod_notes = - mod_notes - |> MarkdownRenderer.render_collection(conn) - |> Enum.zip(mod_notes) + renderer = &MarkdownRenderer.render_collection(&1, conn) + mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", 1, renderer) name_changes = UserNameChange diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index 75f476d4..5cda70a3 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -14,8 +14,7 @@ defmodule PhilomenaWeb.ProfileController do alias Philomena.Tags.Tag alias Philomena.UserIps.UserIp alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.ModNotes.ModNote - alias Philomena.Polymorphic + alias Philomena.ModNotes alias Philomena.Images.Image alias Philomena.Repo import Ecto.Query @@ -275,21 +274,10 @@ defmodule PhilomenaWeb.ProfileController do defp set_mod_notes(conn, _opts) do case Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do true -> + renderer = &MarkdownRenderer.render_collection(&1, conn) user = conn.assigns.user - mod_notes = - ModNote - |> where(notable_type: "User", notable_id: ^user.id) - |> order_by(desc: :id) - |> preload(:moderator) - |> Repo.all() - |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type]) - - mod_notes = - mod_notes - |> MarkdownRenderer.render_collection(conn) - |> Enum.zip(mod_notes) - + mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", user.id, renderer) assign(conn, :mod_notes, mod_notes) _false -> diff --git a/lib/philomena_web/templates/admin/mod_note/_table.html.slime b/lib/philomena_web/templates/admin/mod_note/_table.html.slime index 45207800..b872ace6 100644 --- a/lib/philomena_web/templates/admin/mod_note/_table.html.slime +++ b/lib/philomena_web/templates/admin/mod_note/_table.html.slime @@ -7,7 +7,7 @@ table.table td Moderator td Actions tbody - = for {body, note} <- @mod_notes do + = for {note, body} <- @mod_notes do tr td = link_to_noted_thing(note.notable) diff --git a/lib/philomena_web/templates/profile/show.html.slime b/lib/philomena_web/templates/profile/show.html.slime index 6039a48d..b8644222 100644 --- a/lib/philomena_web/templates/profile/show.html.slime +++ b/lib/philomena_web/templates/profile/show.html.slime @@ -144,7 +144,7 @@ th Note th Created tbody - = for {body, mod_note} <- @mod_notes do + = for {mod_note, body} <- @mod_notes do tr td = body td = pretty_time(mod_note.created_at) From 43bde0e72c54bcc9215ad8ab8a5cb4818104c31e Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Jul 2024 21:27:41 -0400 Subject: [PATCH 020/115] Fixup --- lib/philomena_web/controllers/profile/detail_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/philomena_web/controllers/profile/detail_controller.ex b/lib/philomena_web/controllers/profile/detail_controller.ex index c891b6ca..42681d03 100644 --- a/lib/philomena_web/controllers/profile/detail_controller.ex +++ b/lib/philomena_web/controllers/profile/detail_controller.ex @@ -20,7 +20,7 @@ defmodule PhilomenaWeb.Profile.DetailController do user = conn.assigns.user renderer = &MarkdownRenderer.render_collection(&1, conn) - mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", 1, renderer) + mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", user.id, renderer) name_changes = UserNameChange From d91667d70ad304a0ccb313b7d2e9e451013cfddb Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 16 Jul 2024 18:44:01 -0400 Subject: [PATCH 021/115] Remove unused contexts --- lib/philomena/user_whitelists.ex | 104 ------------------ .../user_whitelists/user_whitelist.ex | 20 ---- lib/philomena/vpns.ex | 104 ------------------ lib/philomena/vpns/vpn.ex | 17 --- 4 files changed, 245 deletions(-) delete mode 100644 lib/philomena/user_whitelists.ex delete mode 100644 lib/philomena/user_whitelists/user_whitelist.ex delete mode 100644 lib/philomena/vpns.ex delete mode 100644 lib/philomena/vpns/vpn.ex diff --git a/lib/philomena/user_whitelists.ex b/lib/philomena/user_whitelists.ex deleted file mode 100644 index 35615c23..00000000 --- a/lib/philomena/user_whitelists.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Philomena.UserWhitelists do - @moduledoc """ - The UserWhitelists context. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.UserWhitelists.UserWhitelist - - @doc """ - Returns the list of user_whitelists. - - ## Examples - - iex> list_user_whitelists() - [%UserWhitelist{}, ...] - - """ - def list_user_whitelists do - Repo.all(UserWhitelist) - end - - @doc """ - Gets a single user_whitelist. - - Raises `Ecto.NoResultsError` if the User whitelist does not exist. - - ## Examples - - iex> get_user_whitelist!(123) - %UserWhitelist{} - - iex> get_user_whitelist!(456) - ** (Ecto.NoResultsError) - - """ - def get_user_whitelist!(id), do: Repo.get!(UserWhitelist, id) - - @doc """ - Creates a user_whitelist. - - ## Examples - - iex> create_user_whitelist(%{field: value}) - {:ok, %UserWhitelist{}} - - iex> create_user_whitelist(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_user_whitelist(attrs \\ %{}) do - %UserWhitelist{} - |> UserWhitelist.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a user_whitelist. - - ## Examples - - iex> update_user_whitelist(user_whitelist, %{field: new_value}) - {:ok, %UserWhitelist{}} - - iex> update_user_whitelist(user_whitelist, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_user_whitelist(%UserWhitelist{} = user_whitelist, attrs) do - user_whitelist - |> UserWhitelist.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a UserWhitelist. - - ## Examples - - iex> delete_user_whitelist(user_whitelist) - {:ok, %UserWhitelist{}} - - iex> delete_user_whitelist(user_whitelist) - {:error, %Ecto.Changeset{}} - - """ - def delete_user_whitelist(%UserWhitelist{} = user_whitelist) do - Repo.delete(user_whitelist) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking user_whitelist changes. - - ## Examples - - iex> change_user_whitelist(user_whitelist) - %Ecto.Changeset{source: %UserWhitelist{}} - - """ - def change_user_whitelist(%UserWhitelist{} = user_whitelist) do - UserWhitelist.changeset(user_whitelist, %{}) - end -end diff --git a/lib/philomena/user_whitelists/user_whitelist.ex b/lib/philomena/user_whitelists/user_whitelist.ex deleted file mode 100644 index 7bee5b36..00000000 --- a/lib/philomena/user_whitelists/user_whitelist.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Philomena.UserWhitelists.UserWhitelist do - use Ecto.Schema - import Ecto.Changeset - - alias Philomena.Users.User - - schema "user_whitelists" do - belongs_to :user, User - - field :reason, :string - timestamps(inserted_at: :created_at, type: :utc_datetime) - end - - @doc false - def changeset(user_whitelist, attrs) do - user_whitelist - |> cast(attrs, []) - |> validate_required([]) - end -end diff --git a/lib/philomena/vpns.ex b/lib/philomena/vpns.ex deleted file mode 100644 index b24254ec..00000000 --- a/lib/philomena/vpns.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Philomena.Vpns do - @moduledoc """ - The Vpns context. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.Vpns.Vpn - - @doc """ - Returns the list of vpns. - - ## Examples - - iex> list_vpns() - [%Vpn{}, ...] - - """ - def list_vpns do - Repo.all(Vpn) - end - - @doc """ - Gets a single vpn. - - Raises `Ecto.NoResultsError` if the Vpn does not exist. - - ## Examples - - iex> get_vpn!(123) - %Vpn{} - - iex> get_vpn!(456) - ** (Ecto.NoResultsError) - - """ - def get_vpn!(id), do: Repo.get!(Vpn, id) - - @doc """ - Creates a vpn. - - ## Examples - - iex> create_vpn(%{field: value}) - {:ok, %Vpn{}} - - iex> create_vpn(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_vpn(attrs \\ %{}) do - %Vpn{} - |> Vpn.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a vpn. - - ## Examples - - iex> update_vpn(vpn, %{field: new_value}) - {:ok, %Vpn{}} - - iex> update_vpn(vpn, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_vpn(%Vpn{} = vpn, attrs) do - vpn - |> Vpn.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Vpn. - - ## Examples - - iex> delete_vpn(vpn) - {:ok, %Vpn{}} - - iex> delete_vpn(vpn) - {:error, %Ecto.Changeset{}} - - """ - def delete_vpn(%Vpn{} = vpn) do - Repo.delete(vpn) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking vpn changes. - - ## Examples - - iex> change_vpn(vpn) - %Ecto.Changeset{source: %Vpn{}} - - """ - def change_vpn(%Vpn{} = vpn) do - Vpn.changeset(vpn, %{}) - end -end diff --git a/lib/philomena/vpns/vpn.ex b/lib/philomena/vpns/vpn.ex deleted file mode 100644 index 6ce9fdd9..00000000 --- a/lib/philomena/vpns/vpn.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Philomena.Vpns.Vpn do - use Ecto.Schema - import Ecto.Changeset - - @primary_key false - - schema "vpns" do - field :ip, EctoNetwork.INET - end - - @doc false - def changeset(vpn, attrs) do - vpn - |> cast(attrs, []) - |> validate_required([]) - end -end From 01fd397f93ad6778f12895a52b07a3df17f5f8dc Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 16 Jul 2024 19:12:12 -0400 Subject: [PATCH 022/115] Fixup --- lib/philomena_web/controllers/profile_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index 5cda70a3..af5b0ac3 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -14,6 +14,7 @@ defmodule PhilomenaWeb.ProfileController do alias Philomena.Tags.Tag alias Philomena.UserIps.UserIp alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.ModNotes.ModNote alias Philomena.ModNotes alias Philomena.Images.Image alias Philomena.Repo From 8f3f2c981bd8131d289f56baf16ed6ce5f8347b2 Mon Sep 17 00:00:00 2001 From: mdashlw Date: Fri, 19 Jul 2024 02:01:27 -0700 Subject: [PATCH 023/115] feat: support limit param in reverse search --- lib/philomena/duplicate_reports.ex | 4 ++-- .../api/json/search/reverse_controller.ex | 1 + lib/philomena_web/image_reverse.ex | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index 3e07151e..d802f364 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -25,7 +25,7 @@ defmodule Philomena.DuplicateReports do end) end - def duplicates_of(intensities, aspect_ratio, dist \\ 0.25, aspect_dist \\ 0.05) do + def duplicates_of(intensities, aspect_ratio, dist \\ 0.25, aspect_dist \\ 0.05, limit \\ 10) do # for each color channel dist = dist * 3 @@ -39,7 +39,7 @@ defmodule Philomena.DuplicateReports do where: i.image_aspect_ratio >= ^(aspect_ratio - aspect_dist) and i.image_aspect_ratio <= ^(aspect_ratio + aspect_dist), - limit: 10 + limit: ^limit end @doc """ diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index e1cf4d7c..ba94b753 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -13,6 +13,7 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do images = image_params |> Map.put("distance", conn.params["distance"]) + |> Map.put("limit", conn.params["limit"]) |> ImageReverse.images() interactions = Interactions.user_interactions(images, user) diff --git a/lib/philomena_web/image_reverse.ex b/lib/philomena_web/image_reverse.ex index 161ebad3..2ef5e427 100644 --- a/lib/philomena_web/image_reverse.ex +++ b/lib/philomena_web/image_reverse.ex @@ -18,8 +18,9 @@ defmodule PhilomenaWeb.ImageReverse do {width, height} = analysis.dimensions aspect = width / height dist = normalize_dist(image_params) + limit = parse_limit(image_params) - DuplicateReports.duplicates_of(intensities, aspect, dist, dist) + DuplicateReports.duplicates_of(intensities, aspect, dist, dist, limit) |> preload([:user, :intensity, [:sources, tags: :aliases]]) |> Repo.all() end @@ -60,4 +61,17 @@ defmodule PhilomenaWeb.ImageReverse do 0.0 end end + + defp parse_limit(%{"limit" => limit}) do + limit + |> Integer.parse() + |> case do + {limit, _rest} -> limit + _ -> 10 + end + |> max(1) + |> min(50) + end + + defp parse_limit(_), do: 10 end From 79f508f6031e7ccec54c0a39a8c2ea9449d7f6eb Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 19 Jul 2024 08:50:16 -0400 Subject: [PATCH 024/115] Improve readability of duplicate report frontend parsing / generation --- lib/philomena/duplicate_reports.ex | 9 ++++- lib/philomena_web/image_reverse.ex | 37 +++++++++---------- .../templates/search/reverse/index.html.slime | 2 + 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index d802f364..9bf85cdb 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -15,7 +15,8 @@ defmodule Philomena.DuplicateReports do def generate_reports(source) do source = Repo.preload(source, :intensity) - duplicates_of(source.intensity, source.image_aspect_ratio, 0.2, 0.05) + {source.intensity, source.image_aspect_ratio} + |> find_duplicates(dist: 0.2) |> where([i, _it], i.id != ^source.id) |> Repo.all() |> Enum.map(fn target -> @@ -25,7 +26,11 @@ defmodule Philomena.DuplicateReports do end) end - def duplicates_of(intensities, aspect_ratio, dist \\ 0.25, aspect_dist \\ 0.05, limit \\ 10) do + def find_duplicates({intensities, aspect_ratio}, opts \\ []) do + aspect_dist = Keyword.get(opts, :aspect_dist, 0.05) + limit = Keyword.get(opts, :limit, 10) + dist = Keyword.get(opts, :dist, 0.25) + # for each color channel dist = dist * 3 diff --git a/lib/philomena_web/image_reverse.ex b/lib/philomena_web/image_reverse.ex index 2ef5e427..e8e32f85 100644 --- a/lib/philomena_web/image_reverse.ex +++ b/lib/philomena_web/image_reverse.ex @@ -17,10 +17,11 @@ defmodule PhilomenaWeb.ImageReverse do {analysis, intensities} -> {width, height} = analysis.dimensions aspect = width / height - dist = normalize_dist(image_params) + dist = parse_dist(image_params) limit = parse_limit(image_params) - DuplicateReports.duplicates_of(intensities, aspect, dist, dist, limit) + {intensities, aspect} + |> DuplicateReports.find_duplicates(dist: dist, aspect_dist: dist, limit: limit) |> preload([:user, :intensity, [:sources, tags: :aliases]]) |> Repo.all() end @@ -43,25 +44,18 @@ defmodule PhilomenaWeb.ImageReverse do # The distance metric is taxicab distance, not Euclidean, # because this is more efficient to index. - defp normalize_dist(%{"distance" => distance}) do + defp parse_dist(%{"distance" => distance}) do distance - |> parse_dist() - |> max(0.01) - |> min(1.0) - end - - defp normalize_dist(_dist), do: 0.25 - - defp parse_dist(dist) do - case Decimal.parse(dist) do - {value, _rest} -> - Decimal.to_float(value) - - _ -> - 0.0 + |> Decimal.parse() + |> case do + {value, _rest} -> Decimal.to_float(value) + _ -> 0.25 end + |> clamp(0.01, 1.0) end + defp parse_dist(_params), do: 0.25 + defp parse_limit(%{"limit" => limit}) do limit |> Integer.parse() @@ -69,9 +63,12 @@ defmodule PhilomenaWeb.ImageReverse do {limit, _rest} -> limit _ -> 10 end - |> max(1) - |> min(50) + |> clamp(1, 50) end - defp parse_limit(_), do: 10 + defp parse_limit(_params), do: 10 + + defp clamp(n, min, _max) when n < min, do: min + defp clamp(n, _min, max) when n > max, do: max + defp clamp(n, _min, _max), do: n end diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime index 876be913..d8b934df 100644 --- a/lib/philomena_web/templates/search/reverse/index.html.slime +++ b/lib/philomena_web/templates/search/reverse/index.html.slime @@ -28,6 +28,8 @@ h1 Reverse Search br = number_input f, :distance, value: 0.25, min: 0, max: 1, step: 0.01, class: "input" + = hidden_input f, :limit, value: 10 + .field = submit "Reverse Search", class: "button" From e6a63f4c2514624ece1e610a69cd546aeec5636f Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 19 Jul 2024 09:58:19 -0400 Subject: [PATCH 025/115] Bump req --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 4d006ee9..6aff7050 100644 --- a/mix.lock +++ b/mix.lock @@ -67,7 +67,7 @@ "qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"}, "redix": {:hex, :redix, "1.5.1", "a2386971e69bf23630fb3a215a831b5478d2ee7dc9ea7ac811ed89186ab5d7b7", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "85224eb2b683c516b80d472eb89b76067d5866913bf0be59d646f550de71f5c4"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, - "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"}, + "req": {:hex, :req, "0.5.1", "90584216d064389a4ff2d4279fe2c11ff6c812ab00fa01a9fb9d15457f65ba70", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7ea96a1a95388eb0fefa92d89466cdfedba24032794e5c1147d78ec90db7edca"}, "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, "rustler": {:hex, :rustler, "0.33.0", "4a5b0a7a7b0b51549bea49947beff6fae9bc5d5326104dcd4531261e876b5619", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "7c4752728fee59a815ffd20c3429c55b644041f25129b29cdeb5c470b80ec5fd"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, From 7a5d26144ad374ec08681749128dfbd7af3dc616 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 19 Jul 2024 09:29:02 -0400 Subject: [PATCH 026/115] Convert reverse search parsing to changeset-backed form --- lib/philomena/duplicate_reports.ex | 59 +++++++++++++++ .../duplicate_reports/search_query.ex | 59 +++++++++++++++ lib/philomena/duplicate_reports/uploader.ex | 17 +++++ .../api/json/search/reverse_controller.ex | 11 ++- .../controllers/search/reverse_controller.ex | 15 +++- lib/philomena_web/image_reverse.ex | 74 ------------------- .../templates/search/reverse/index.html.slime | 11 ++- 7 files changed, 163 insertions(+), 83 deletions(-) create mode 100644 lib/philomena/duplicate_reports/search_query.ex create mode 100644 lib/philomena/duplicate_reports/uploader.ex delete mode 100644 lib/philomena_web/image_reverse.ex diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index 9bf85cdb..5f84c9da 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -8,6 +8,8 @@ defmodule Philomena.DuplicateReports do alias Philomena.Repo alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports.SearchQuery + alias Philomena.DuplicateReports.Uploader alias Philomena.ImageIntensities.ImageIntensity alias Philomena.Images.Image alias Philomena.Images @@ -47,6 +49,63 @@ defmodule Philomena.DuplicateReports do limit: ^limit end + @doc """ + Executes the reverse image search query from parameters. + + ## Examples + + iex> execute_search_query(%{"image" => ..., "distance" => "0.25"}) + {:ok, [%Image{...}, ....]} + + iex> execute_search_query(%{"image" => ..., "distance" => "asdf"}) + {:error, %Ecto.Changeset{}} + + """ + def execute_search_query(attrs \\ %{}) do + %SearchQuery{} + |> SearchQuery.changeset(attrs) + |> Uploader.analyze_upload(attrs) + |> Ecto.Changeset.apply_action(:create) + |> case do + {:ok, search_query} -> + intensities = generate_intensities(search_query) + aspect = search_query.image_aspect_ratio + limit = search_query.limit + dist = search_query.distance + + images = + {intensities, aspect} + |> find_duplicates(dist: dist, aspect_dist: dist, limit: limit) + |> preload([:user, :intensity, [:sources, tags: :aliases]]) + |> Repo.all() + + {:ok, images} + + error -> + error + end + end + + defp generate_intensities(search_query) do + analysis = SearchQuery.to_analysis(search_query) + file = search_query.uploaded_image + + PhilomenaMedia.Processors.intensities(analysis, file) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking search query changes. + + ## Examples + + iex> change_search_query(search_query) + %Ecto.Changeset{source: %SearchQuery{}} + + """ + def change_search_query(%SearchQuery{} = search_query) do + SearchQuery.changeset(search_query) + end + @doc """ Gets a single duplicate_report. diff --git a/lib/philomena/duplicate_reports/search_query.ex b/lib/philomena/duplicate_reports/search_query.ex new file mode 100644 index 00000000..bc922077 --- /dev/null +++ b/lib/philomena/duplicate_reports/search_query.ex @@ -0,0 +1,59 @@ +defmodule Philomena.DuplicateReports.SearchQuery do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :distance, :float, default: 0.25 + field :limit, :integer, default: 10 + + field :image_width, :integer + field :image_height, :integer + field :image_format, :string + field :image_duration, :float + field :image_mime_type, :string + field :image_is_animated, :boolean + field :image_aspect_ratio, :float + field :uploaded_image, :string, virtual: true + end + + @doc false + def changeset(search_query, attrs \\ %{}) do + search_query + |> cast(attrs, [:distance, :limit]) + |> validate_number(:distance, greater_than_or_equal_to: 0, less_than_or_equal_to: 1) + |> validate_number(:limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 50) + end + + @doc false + def image_changeset(search_query, attrs \\ %{}) do + search_query + |> cast(attrs, [ + :image_width, + :image_height, + :image_format, + :image_duration, + :image_mime_type, + :image_is_animated, + :image_aspect_ratio, + :uploaded_image + ]) + |> validate_number(:image_width, greater_than: 0) + |> validate_number(:image_height, greater_than: 0) + |> validate_inclusion( + :image_mime_type, + ~W(image/gif image/jpeg image/png image/svg+xml video/webm), + message: "(#{attrs["image_mime_type"]}) is invalid" + ) + end + + @doc false + def to_analysis(search_query) do + %PhilomenaMedia.Analyzers.Result{ + animated?: search_query.image_is_animated, + dimensions: {search_query.image_width, search_query.image_height}, + duration: search_query.image_duration, + extension: search_query.image_format, + mime_type: search_query.image_mime_type + } + end +end diff --git a/lib/philomena/duplicate_reports/uploader.ex b/lib/philomena/duplicate_reports/uploader.ex new file mode 100644 index 00000000..41fc4998 --- /dev/null +++ b/lib/philomena/duplicate_reports/uploader.ex @@ -0,0 +1,17 @@ +defmodule Philomena.DuplicateReports.Uploader do + @moduledoc """ + Upload and processing callback logic for SearchQuery images. + """ + + alias Philomena.DuplicateReports.SearchQuery + alias PhilomenaMedia.Uploader + + def analyze_upload(search_query, params) do + Uploader.analyze_upload( + search_query, + "image", + params["image"], + &SearchQuery.image_changeset/2 + ) + end +end diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index ba94b753..1b7a6011 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -1,7 +1,7 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageReverse + alias Philomena.DuplicateReports alias Philomena.Interactions plug PhilomenaWeb.ScraperCachePlug @@ -14,7 +14,14 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do image_params |> Map.put("distance", conn.params["distance"]) |> Map.put("limit", conn.params["limit"]) - |> ImageReverse.images() + |> DuplicateReports.execute_search_query() + |> case do + {:ok, images} -> + images + + {:error, _changeset} -> + [] + end interactions = Interactions.user_interactions(images, user) diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex index 54c52dac..967b968a 100644 --- a/lib/philomena_web/controllers/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/search/reverse_controller.ex @@ -1,7 +1,8 @@ defmodule PhilomenaWeb.Search.ReverseController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageReverse + alias Philomena.DuplicateReports.SearchQuery + alias Philomena.DuplicateReports plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image" @@ -12,12 +13,18 @@ defmodule PhilomenaWeb.Search.ReverseController do def create(conn, %{"image" => image_params}) when is_map(image_params) and image_params != %{} do - images = ImageReverse.images(image_params) + case DuplicateReports.execute_search_query(image_params) do + {:ok, images} -> + changeset = DuplicateReports.change_search_query(%SearchQuery{}) + render(conn, "index.html", title: "Reverse Search", images: images, changeset: changeset) - render(conn, "index.html", title: "Reverse Search", images: images) + {:error, changeset} -> + render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset) + end end def create(conn, _params) do - render(conn, "index.html", title: "Reverse Search", images: nil) + changeset = DuplicateReports.change_search_query(%SearchQuery{}) + render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset) end end diff --git a/lib/philomena_web/image_reverse.ex b/lib/philomena_web/image_reverse.ex deleted file mode 100644 index e8e32f85..00000000 --- a/lib/philomena_web/image_reverse.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule PhilomenaWeb.ImageReverse do - alias PhilomenaMedia.Analyzers - alias PhilomenaMedia.Processors - alias Philomena.DuplicateReports - alias Philomena.Repo - import Ecto.Query - - def images(image_params) do - image_params - |> Map.get("image") - |> analyze() - |> intensities() - |> case do - :error -> - [] - - {analysis, intensities} -> - {width, height} = analysis.dimensions - aspect = width / height - dist = parse_dist(image_params) - limit = parse_limit(image_params) - - {intensities, aspect} - |> DuplicateReports.find_duplicates(dist: dist, aspect_dist: dist, limit: limit) - |> preload([:user, :intensity, [:sources, tags: :aliases]]) - |> Repo.all() - end - end - - defp analyze(%Plug.Upload{path: path}) do - case Analyzers.analyze(path) do - {:ok, analysis} -> {analysis, path} - _ -> :error - end - end - - defp analyze(_upload), do: :error - - defp intensities(:error), do: :error - - defp intensities({analysis, path}) do - {analysis, Processors.intensities(analysis, path)} - end - - # The distance metric is taxicab distance, not Euclidean, - # because this is more efficient to index. - defp parse_dist(%{"distance" => distance}) do - distance - |> Decimal.parse() - |> case do - {value, _rest} -> Decimal.to_float(value) - _ -> 0.25 - end - |> clamp(0.01, 1.0) - end - - defp parse_dist(_params), do: 0.25 - - defp parse_limit(%{"limit" => limit}) do - limit - |> Integer.parse() - |> case do - {limit, _rest} -> limit - _ -> 10 - end - |> clamp(1, 50) - end - - defp parse_limit(_params), do: 10 - - defp clamp(n, min, _max) when n < min, do: min - defp clamp(n, _min, max) when n > max, do: max - defp clamp(n, _min, _max), do: n -end diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime index d8b934df..83dd72a7 100644 --- a/lib/philomena_web/templates/search/reverse/index.html.slime +++ b/lib/philomena_web/templates/search/reverse/index.html.slime @@ -1,6 +1,6 @@ h1 Reverse Search -= form_for :image, ~p"/search/reverse", [multipart: true], fn f -> += form_for @changeset, ~p"/search/reverse", [multipart: true, as: :image], fn f -> p ' Basic image similarity search. Finds uploaded images similar to the one ' provided based on simple intensities and uses the median frame of @@ -13,6 +13,10 @@ h1 Reverse Search p Upload a file from your computer, or provide a link to the page containing the image and click Fetch. .field = file_input f, :image, class: "input js-scraper" + = error_tag f, :image + = error_tag f, :image_width + = error_tag f, :image_height + = error_tag f, :image_mime_type .field.field--inline = url_input f, :url, name: "url", class: "input input--wide js-scraper", placeholder: "Link a deviantART page, a Tumblr post, or the image directly" @@ -26,9 +30,10 @@ h1 Reverse Search .field = label f, :distance, "Match distance (suggested values: between 0.2 and 0.5)" br - = number_input f, :distance, value: 0.25, min: 0, max: 1, step: 0.01, class: "input" + = number_input f, :distance, min: 0, max: 1, step: 0.01, class: "input" + = error_tag f, :distance - = hidden_input f, :limit, value: 10 + = error_tag f, :limit .field = submit "Reverse Search", class: "button" From ee57b9d9713842b026b1813837727b6b03105e3f Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 19 Jul 2024 16:35:07 -0400 Subject: [PATCH 027/115] Add more thorough user change eraser --- lib/philomena/users.ex | 15 +++ lib/philomena/users/eraser.ex | 125 ++++++++++++++++++ lib/philomena/workers/user_erase_worker.ex | 11 ++ .../admin/user/erase_controller.ex | 74 +++++++++++ lib/philomena_web/router.ex | 1 + .../templates/admin/user/erase/new.html.slime | 16 +++ .../templates/profile/_admin_block.html.slime | 6 + .../views/admin/user/erase_view.ex | 3 + 8 files changed, 251 insertions(+) create mode 100644 lib/philomena/users/eraser.ex create mode 100644 lib/philomena/workers/user_erase_worker.ex create mode 100644 lib/philomena_web/controllers/admin/user/erase_controller.ex create mode 100644 lib/philomena_web/templates/admin/user/erase/new.html.slime create mode 100644 lib/philomena_web/views/admin/user/erase_view.ex diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index b971f0c9..013b6173 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -18,6 +18,7 @@ defmodule Philomena.Users do alias Philomena.Galleries alias Philomena.Reports alias Philomena.Filters + alias Philomena.UserEraseWorker alias Philomena.UserRenameWorker ## Database getters @@ -683,6 +684,20 @@ defmodule Philomena.Users do |> Repo.update() end + def erase_user(%User{} = user, %User{} = moderator) do + # Deactivate to prevent the user from racing these changes + {:ok, user} = deactivate_user(moderator, user) + + # Rename to prevent usage for brand recognition SEO + random_hex = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + {:ok, user} = update_user(user, %{name: "deactivated_#{random_hex}"}) + + # Enqueue a background job to perform the rest of the deletion + Exq.enqueue(Exq, "indexing", UserEraseWorker, [user.id, moderator.id]) + + {:ok, user} + end + defp setup_roles(nil), do: nil defp setup_roles(user) do diff --git a/lib/philomena/users/eraser.ex b/lib/philomena/users/eraser.ex new file mode 100644 index 00000000..d584ac77 --- /dev/null +++ b/lib/philomena/users/eraser.ex @@ -0,0 +1,125 @@ +defmodule Philomena.Users.Eraser do + import Ecto.Query + alias Philomena.Repo + + alias Philomena.Bans + alias Philomena.Comments.Comment + alias Philomena.Comments + alias Philomena.Galleries.Gallery + alias Philomena.Galleries + alias Philomena.Posts.Post + alias Philomena.Posts + alias Philomena.Topics.Topic + alias Philomena.Topics + alias Philomena.Images + alias Philomena.SourceChanges.SourceChange + + alias Philomena.Users + + @reason "Site abuse" + @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32} + @wipe_fp "ffff" + + def erase_permanently!(user, moderator) do + # Erase avatar + {:ok, user} = Users.remove_avatar(user) + + # Erase "about me" and personal title + {:ok, user} = Users.update_description(user, %{description: "", personal_title: ""}) + + # Delete all forum posts + Post + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn post -> + {:ok, post} = Posts.hide_post(post, %{deletion_reason: @reason}, moderator) + {:ok, _post} = Posts.destroy_post(post) + end) + + # Delete all comments + Comment + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn comment -> + {:ok, comment} = Comments.hide_comment(comment, %{deletion_reason: @reason}, moderator) + {:ok, _comment} = Comments.destroy_comment(comment) + end) + + # Delete all galleries + Gallery + |> where(creator_id: ^user.id) + |> Repo.all() + |> Enum.each(fn gallery -> + {:ok, _gallery} = Galleries.delete_gallery(gallery) + end) + + # Delete all posted topics + Topic + |> where(user_id: ^user.id) + |> Repo.all() + |> Enum.each(fn topic -> + {:ok, _topic} = Topics.hide_topic(topic, @reason, moderator) + end) + + # Revert all source changes + SourceChange + |> where(user_id: ^user.id) + |> order_by(desc: :created_at) + |> preload(:image) + |> Repo.all() + |> Enum.each(fn source_change -> + if source_change.added do + revert_added_source_change(source_change, user) + else + revert_removed_source_change(source_change, user) + end + end) + + # Delete all source changes + SourceChange + |> where(user_id: ^user.id) + |> Repo.delete_all() + + # Ban the user + {:ok, _ban} = + Bans.create_user( + moderator, + %{ + "user_id" => user.id, + "reason" => @reason, + "valid_until" => "permanent" + } + ) + + # We succeeded + :ok + end + + defp revert_removed_source_change(source_change, user) do + old_sources = %{} + new_sources = %{"0" => %{"source" => source_change.source_url}} + + revert_source_change(source_change, user, old_sources, new_sources) + end + + defp revert_added_source_change(source_change, user) do + old_sources = %{"0" => %{"source" => source_change.source_url}} + new_sources = %{} + + revert_source_change(source_change, user, old_sources, new_sources) + end + + defp revert_source_change(source_change, user, old_sources, new_sources) do + attrs = %{"old_sources" => old_sources, "sources" => new_sources} + + attribution = [ + user: user, + ip: @wipe_ip, + fingerprint: @wipe_fp, + user_agent: "", + referrer: "" + ] + + {:ok, _} = Images.update_sources(source_change.image, attribution, attrs) + end +end diff --git a/lib/philomena/workers/user_erase_worker.ex b/lib/philomena/workers/user_erase_worker.ex new file mode 100644 index 00000000..32c862d3 --- /dev/null +++ b/lib/philomena/workers/user_erase_worker.ex @@ -0,0 +1,11 @@ +defmodule Philomena.UserEraseWorker do + alias Philomena.Users.Eraser + alias Philomena.Users + + def perform(user_id, moderator_id) do + moderator = Users.get_user!(moderator_id) + user = Users.get_user!(user_id) + + Eraser.erase_permanently!(user, moderator) + end +end diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex new file mode 100644 index 00000000..d101db57 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex @@ -0,0 +1,74 @@ +defmodule PhilomenaWeb.Admin.User.EraseController do + use PhilomenaWeb, :controller + + alias Philomena.Users.User + alias Philomena.Users + + plug :verify_authorized + + plug :load_resource, + model: User, + id_name: "user_id", + id_field: "slug", + persisted: true, + preload: [:roles] + + plug :prevent_deleting_privileged_users + plug :prevent_deleting_verified_users + plug :prevent_deleting_old_users + + def new(conn, _params) do + render(conn, "new.html", title: "Erase user") + end + + def create(conn, _params) do + {:ok, user} = Users.erase_user(conn.assigns.user, conn.assigns.current_user) + + conn + |> put_flash(:info, "User erase started") + |> redirect(to: ~p"/profiles/#{user}") + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end + + defp prevent_deleting_privileged_users(conn, _opts) do + if conn.assigns.user.role != "user" do + conn + |> put_flash(:error, "Cannot erase a privileged user") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end + + defp prevent_deleting_verified_users(conn, _opts) do + if conn.assigns.user.verified do + conn + |> put_flash(:error, "Cannot erase a verified user") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end + + defp prevent_deleting_old_users(conn, _opts) do + now = DateTime.utc_now(:second) + two_weeks = 1_209_600 + + if DateTime.compare(now, DateTime.add(conn.assigns.user.created_at, two_weeks)) == :gt do + conn + |> put_flash(:error, "Cannot erase a user older than two weeks") + |> redirect(to: ~p"/profiles/#{conn.assigns.user}") + |> Plug.Conn.halt() + else + conn + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 31be1073..be9f48e0 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -398,6 +398,7 @@ defmodule PhilomenaWeb.Router do singleton: true resources "/unlock", User.UnlockController, only: [:create], singleton: true + resources "/erase", User.EraseController, only: [:new, :create], singleton: true resources "/api_key", User.ApiKeyController, only: [:delete], singleton: true resources "/downvotes", User.DownvoteController, only: [:delete], singleton: true resources "/votes", User.VoteController, only: [:delete], singleton: true diff --git a/lib/philomena_web/templates/admin/user/erase/new.html.slime b/lib/philomena_web/templates/admin/user/erase/new.html.slime new file mode 100644 index 00000000..643181e7 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/erase/new.html.slime @@ -0,0 +1,16 @@ +h1 + ' Deleting all changes for user + = @user.name + +.block.block--fixed.block--warning + p This is IRREVERSIBLE. + p All user details will be destroyed. + p Are you really sure? + +.field + => button_to "Abort", ~p"/profiles/#{@user}", class: "button" + => button_to "Erase user", ~p"/admin/users/#{@user}/erase", method: "post", class: "button button--state-danger", data: [confirm: "Are you really, really sure?"] + +p + ' This automatically creates user and IP bans but does not create a fingerprint ban. + ' Check to see if one is necessary after erasing. diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime index 4f827788..9054a457 100644 --- a/lib/philomena_web/templates/profile/_admin_block.html.slime +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -171,6 +171,12 @@ a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__optio i.fa.fa-fw.fa-arrow-down span.admin__button Remove All Downvotes + = if @user.role == "user" do + li + = link to: ~p"/admin/users/#{@user}/erase/new", data: [confirm: "Are you really, really sure?"] do + i.fa.fa-fw.fa-warning + span.admin__button Erase for spam + = if @user.role == "user" and can?(@conn, :revert, Philomena.TagChanges.TagChange) do li = link to: ~p"/tag_changes/full_revert?#{[user_id: @user.id]}", data: [confirm: "Are you really, really sure?", method: "create"] do diff --git a/lib/philomena_web/views/admin/user/erase_view.ex b/lib/philomena_web/views/admin/user/erase_view.ex new file mode 100644 index 00000000..2c497038 --- /dev/null +++ b/lib/philomena_web/views/admin/user/erase_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Admin.User.EraseView do + use PhilomenaWeb, :view +end From 335fc0bc56c6f964b98f53731bf9ea89902b197c Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 19 Jul 2024 18:24:06 -0400 Subject: [PATCH 028/115] Remove time limit --- .../controllers/admin/user/erase_controller.ex | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex index d101db57..b481068e 100644 --- a/lib/philomena_web/controllers/admin/user/erase_controller.ex +++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex @@ -15,7 +15,6 @@ defmodule PhilomenaWeb.Admin.User.EraseController do plug :prevent_deleting_privileged_users plug :prevent_deleting_verified_users - plug :prevent_deleting_old_users def new(conn, _params) do render(conn, "new.html", title: "Erase user") @@ -57,18 +56,4 @@ defmodule PhilomenaWeb.Admin.User.EraseController do conn end end - - defp prevent_deleting_old_users(conn, _opts) do - now = DateTime.utc_now(:second) - two_weeks = 1_209_600 - - if DateTime.compare(now, DateTime.add(conn.assigns.user.created_at, two_weeks)) == :gt do - conn - |> put_flash(:error, "Cannot erase a user older than two weeks") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}") - |> Plug.Conn.halt() - else - conn - end - end end From 731e4d8869d9d2652122b9a94b13ef30ede50851 Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Wed, 10 May 2023 22:43:20 -0400 Subject: [PATCH 029/115] Add some basic limits to anonymous tag changes --- lib/philomena/images.ex | 41 +++++++ lib/philomena/images/image.ex | 1 + lib/philomena/images/tag_validator.ex | 22 +++- lib/philomena/tag_changes/limits.ex | 109 ++++++++++++++++++ .../controllers/image/tag_controller.ex | 13 +++ 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 lib/philomena/tag_changes/limits.ex diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index db95b09a..09705d16 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -24,6 +24,7 @@ defmodule Philomena.Images do alias Philomena.SourceChanges.SourceChange alias Philomena.Notifications.Notification alias Philomena.NotificationWorker + alias Philomena.TagChanges.Limits alias Philomena.TagChanges.TagChange alias Philomena.Tags alias Philomena.UserStatistics @@ -419,6 +420,9 @@ defmodule Philomena.Images do error end end) + |> Multi.run(:check_limits, fn _repo, %{image: {image, _added, _removed}} -> + check_tag_change_limits_before_commit(image, attribution) + end) |> Multi.run(:added_tag_changes, fn repo, %{image: {image, added_tags, _removed}} -> tag_changes = added_tags @@ -462,6 +466,43 @@ defmodule Philomena.Images do {:ok, count} end) |> Repo.transaction() + |> case do + {:ok, %{image: {image, _added, _removed}}} = res -> + update_tag_change_limits_after_commit(image, attribution) + + res + + err -> + err + end + end + + defp check_tag_change_limits_before_commit(image, attribution) do + tag_changed_count = length(image.added_tags) + length(image.removed_tags) + rating_changed = image.ratings_changed + user = attribution[:user] + ip = attribution[:ip] + + cond do + Limits.limited_for_tag_count?(user, ip, tag_changed_count) -> + {:error, :limit_exceeded} + + rating_changed and Limits.limited_for_rating_count?(user, ip) -> + {:error, :limit_exceeded} + + true -> + {:ok, 0} + end + end + + def update_tag_change_limits_after_commit(image, attribution) do + rating_changed_count = if(image.ratings_changed, do: 1, else: 0) + tag_changed_count = length(image.added_tags) + length(image.removed_tags) + user = attribution[:user] + ip = attribution[:ip] + + Limits.update_tag_count_after_update(user, ip, tag_changed_count) + Limits.update_rating_count_after_update(user, ip, rating_changed_count) end defp tag_change_attributes(attribution, image, tag, added, user) do diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 83ce9409..3e9c889a 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -96,6 +96,7 @@ defmodule Philomena.Images.Image do field :added_tags, {:array, :any}, default: [], virtual: true field :removed_sources, {:array, :any}, default: [], virtual: true field :added_sources, {:array, :any}, default: [], virtual: true + field :ratings_changed, :boolean, default: false, virtual: true field :uploaded_image, :string, virtual: true field :removed_image, :string, virtual: true diff --git a/lib/philomena/images/tag_validator.ex b/lib/philomena/images/tag_validator.ex index 887b5daa..8ffe3bdb 100644 --- a/lib/philomena/images/tag_validator.ex +++ b/lib/philomena/images/tag_validator.ex @@ -5,7 +5,20 @@ defmodule Philomena.Images.TagValidator do def validate_tags(changeset) do tags = changeset |> get_field(:tags) - validate_tag_input(changeset, tags) + changeset + |> validate_tag_input(tags) + |> set_rating_changed() + end + + defp set_rating_changed(changeset) do + added_tags = changeset |> get_field(:added_tags) |> extract_names() + removed_tags = changeset |> get_field(:removed_tags) |> extract_names() + ratings = all_ratings() + + added_ratings = MapSet.intersection(ratings, added_tags) |> MapSet.size() + removed_ratings = MapSet.intersection(ratings, removed_tags) |> MapSet.size() + + put_change(changeset, :ratings_changed, added_ratings + removed_ratings > 0) end defp validate_tag_input(changeset, tags) do @@ -108,6 +121,13 @@ defmodule Philomena.Images.TagValidator do |> MapSet.new() end + defp all_ratings do + safe_rating() + |> MapSet.union(sexual_ratings()) + |> MapSet.union(horror_ratings()) + |> MapSet.union(gross_rating()) + end + defp safe_rating, do: MapSet.new(["safe"]) defp sexual_ratings, do: MapSet.new(["suggestive", "questionable", "explicit"]) defp horror_ratings, do: MapSet.new(["semi-grimdark", "grimdark"]) diff --git a/lib/philomena/tag_changes/limits.ex b/lib/philomena/tag_changes/limits.ex new file mode 100644 index 00000000..496ef2c2 --- /dev/null +++ b/lib/philomena/tag_changes/limits.ex @@ -0,0 +1,109 @@ +defmodule Philomena.TagChanges.Limits do + @moduledoc """ + Tag change limits for anonymous users. + """ + + @tag_changes_per_ten_minutes 50 + @rating_changes_per_ten_minutes 1 + @ten_minutes_in_seconds 10 * 60 + + @doc """ + Determine if the current user and IP can make any tag changes at all. + + The user may be limited due to making more than 50 tag changes in the past 10 minutes. + Should be used in tandem with `update_tag_count_after_update/3`. + + ## Examples + + iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}) + false + + iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}, 72) + true + + """ + def limited_for_tag_count?(user, ip, additional \\ 0) do + check_limit(user, tag_count_key_for_ip(ip), @tag_changes_per_ten_minutes, additional) + end + + @doc """ + Determine if the current user and IP can make rating tag changes. + + The user may be limited due to making more than one rating tag change in the past 10 minutes. + Should be used in tandem with `update_rating_count_after_update/3`. + + ## Examples + + iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}) + false + + iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}, 2) + true + + """ + def limited_for_rating_count?(user, ip) do + check_limit(user, rating_count_key_for_ip(ip), @rating_changes_per_ten_minutes, 0) + end + + @doc """ + Post-transaction update for successful tag changes. + + Should be used in tandem with `limited_for_tag_count?/2`. + + ## Examples + + iex> update_tag_count_after_update(%User{}, %Postgrex.INET{}, 25) + :ok + + """ + def update_tag_count_after_update(user, ip, amount) do + increment_counter(user, tag_count_key_for_ip(ip), amount, @ten_minutes_in_seconds) + end + + @doc """ + Post-transaction update for successful rating tag changes. + + Should be used in tandem with `limited_for_rating_count?/2`. + + ## Examples + + iex> update_rating_count_after_update(%User{}, %Postgrex.INET{}, 1) + :ok + + """ + def update_rating_count_after_update(user, ip, amount) do + increment_counter(user, rating_count_key_for_ip(ip), amount, @ten_minutes_in_seconds) + end + + defp check_limit(user, key, limit, additional) do + if considered_for_limit?(user) do + amt = Redix.command!(:redix, ["GET", key]) || 0 + amt + additional >= limit + else + false + end + end + + defp increment_counter(user, key, amount, expiration) do + if considered_for_limit?(user) do + Redix.pipeline!(:redix, [ + ["INCRBY", key, amount], + ["EXPIRE", key, expiration] + ]) + end + + :ok + end + + defp considered_for_limit?(user) do + is_nil(user) or not user.verified + end + + defp tag_count_key_for_ip(ip) do + "rltcn:#{ip}" + end + + defp rating_count_key_for_ip(ip) do + "rltcr:#{ip}" + end +end diff --git a/lib/philomena_web/controllers/image/tag_controller.ex b/lib/philomena_web/controllers/image/tag_controller.ex index 2bae9731..468864c9 100644 --- a/lib/philomena_web/controllers/image/tag_controller.ex +++ b/lib/philomena_web/controllers/image/tag_controller.ex @@ -8,6 +8,7 @@ defmodule PhilomenaWeb.Image.TagController do alias Philomena.Images alias Philomena.Tags alias Philomena.Repo + alias Plug.Conn import Ecto.Query plug PhilomenaWeb.LimitPlug, @@ -88,6 +89,18 @@ defmodule PhilomenaWeb.Image.TagController do image: image, changeset: changeset ) + + {:error, :check_limits, _error, _} -> + conn + |> put_flash(:error, "Too many tags changed. Change fewer tags or try again later.") + |> Conn.send_resp(:multiple_choices, "") + |> Conn.halt() + + _err -> + conn + |> put_flash(:error, "Failed to update tags!") + |> Conn.send_resp(:multiple_choices, "") + |> Conn.halt() end end end From 3f09125425bd1673cd31a70ffb9d2bc2399bccf2 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 20 Jul 2024 21:23:55 -0400 Subject: [PATCH 030/115] Extract query for closing reports --- lib/philomena/comments.ex | 19 ++++-------------- lib/philomena/conversations.ex | 7 +------ lib/philomena/images.ex | 9 ++------- lib/philomena/posts.ex | 18 ++++------------- lib/philomena/reports.ex | 35 ++++++++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index f0ac4dc0..90569d6e 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -8,7 +8,6 @@ defmodule Philomena.Comments do alias Philomena.Repo alias PhilomenaQuery.Search - alias Philomena.Reports.Report alias Philomena.UserStatistics alias Philomena.Comments.Comment alias Philomena.Comments.SearchIndex, as: CommentIndex @@ -145,17 +144,12 @@ defmodule Philomena.Comments do end def hide_comment(%Comment{} = comment, attrs, user) do - reports = - Report - |> where(reportable_type: "Comment", reportable_id: ^comment.id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) - + report_query = Reports.close_report_query("Comment", comment.id, user) comment = Comment.hide_changeset(comment, attrs, user) Multi.new() |> Multi.update(:comment, comment) - |> Multi.update_all(:reports, reports, []) + |> Multi.update_all(:reports, report_query, []) |> Repo.transaction() |> case do {:ok, %{comment: comment, reports: {_count, reports}}} -> @@ -191,17 +185,12 @@ defmodule Philomena.Comments do end def approve_comment(%Comment{} = comment, user) do - reports = - Report - |> where(reportable_type: "Comment", reportable_id: ^comment.id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) - + report_query = Reports.close_report_query("Comment", comment.id, user) comment = Comment.approve_changeset(comment) Multi.new() |> Multi.update(:comment, comment) - |> Multi.update_all(:reports, reports, []) + |> Multi.update_all(:reports, report_query, []) |> Repo.transaction() |> case do {:ok, %{comment: comment, reports: {_count, reports}}} -> diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index 597b64f7..e9ed7500 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -7,7 +7,6 @@ defmodule Philomena.Conversations do alias Ecto.Multi alias Philomena.Repo alias Philomena.Reports - alias Philomena.Reports.Report alias Philomena.Conversations.Conversation @doc """ @@ -209,11 +208,7 @@ defmodule Philomena.Conversations do end def approve_conversation_message(message, user) do - reports_query = - Report - |> where(reportable_type: "Conversation", reportable_id: ^message.conversation_id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) + reports_query = Reports.close_report_query("Conversation", message.conversation_id, user) message_query = message diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index db95b09a..5fea2e35 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -31,7 +31,6 @@ defmodule Philomena.Images do alias Philomena.Notifications alias Philomena.Interactions alias Philomena.Reports - alias Philomena.Reports.Report alias Philomena.Comments alias Philomena.Galleries.Gallery alias Philomena.Galleries.Interaction @@ -578,11 +577,7 @@ defmodule Philomena.Images do end defp hide_image_multi(changeset, image, user, multi) do - reports = - Report - |> where(reportable_type: "Image", reportable_id: ^image.id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) + report_query = Reports.close_report_query("Image", image.id, user) galleries = Gallery @@ -593,7 +588,7 @@ defmodule Philomena.Images do multi |> Multi.update(:image, changeset) - |> Multi.update_all(:reports, reports, []) + |> Multi.update_all(:reports, report_query, []) |> Multi.update_all(:galleries, galleries, []) |> Multi.delete_all(:gallery_interactions, gallery_interactions, []) |> Multi.run(:tags, fn repo, %{image: image} -> diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index fa46f406..fc339c97 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -19,7 +19,6 @@ defmodule Philomena.Posts do alias Philomena.NotificationWorker alias Philomena.Versions alias Philomena.Reports - alias Philomena.Reports.Report @doc """ Gets a single post. @@ -204,11 +203,7 @@ defmodule Philomena.Posts do end def hide_post(%Post{} = post, attrs, user) do - reports = - Report - |> where(reportable_type: "Post", reportable_id: ^post.id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) + report_query = Reports.close_report_query("Post", post.id, user) topics = Topic @@ -224,7 +219,7 @@ defmodule Philomena.Posts do Multi.new() |> Multi.update(:post, post) - |> Multi.update_all(:reports, reports, []) + |> Multi.update_all(:reports, report_query, []) |> Multi.update_all(:topics, topics, []) |> Multi.update_all(:forums, forums, []) |> Repo.transaction() @@ -255,17 +250,12 @@ defmodule Philomena.Posts do end def approve_post(%Post{} = post, user) do - reports = - Report - |> where(reportable_type: "Post", reportable_id: ^post.id) - |> select([r], r.id) - |> update(set: [open: false, state: "closed", admin_id: ^user.id]) - + report_query = Reports.close_report_query("Post", post.id, user) post = Post.approve_changeset(post) Multi.new() |> Multi.update(:post, post) - |> Multi.update_all(:reports, reports, []) + |> Multi.update_all(:reports, report_query, []) |> Repo.transaction() |> case do {:ok, %{post: post, reports: {_count, reports}}} -> diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 1639929d..4f10391d 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -60,6 +60,41 @@ defmodule Philomena.Reports do |> reindex_after_update() end + @doc """ + Returns an `m:Ecto.Query` which updates all reports for the given `reportable_type` + and `reportable_id` to close them. + + Because this is only a query due to the limitations of `m:Ecto.Multi`, this must be + coupled with an associated call to `reindex_reports/1` to operate correctly, e.g.: + + report_query = Reports.close_system_report_query("Image", image.id, user) + + Multi.new() + |> Multi.update_all(:reports, report_query, []) + |> Repo.transaction() + |> case do + {:ok, %{reports: {_count, reports}} = result} -> + Reports.reindex_reports(reports) + + {:ok, result} + + error -> + error + end + + ## Examples + + iex> close_system_report_query("Image", 1, %User{}) + #Ecto.Query<...> + + """ + def close_report_query(reportable_type, reportable_id, closing_user) do + from r in Report, + where: r.reportable_type == ^reportable_type and r.reportable_id == ^reportable_id, + select: r.id, + update: [set: [open: false, state: "closed", admin_id: ^closing_user.id]] + end + def create_system_report(reportable_id, reportable_type, category, reason) do attrs = %{ reason: reason, From c9bcda4e0a4876e007725ab8c5b96dd05eda042f Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 20 Jul 2024 22:20:13 -0400 Subject: [PATCH 031/115] Document create_system_report and flip argument order --- lib/philomena/comments.ex | 2 +- lib/philomena/conversations.ex | 2 +- lib/philomena/images.ex | 2 +- lib/philomena/posts.ex | 2 +- lib/philomena/reports.ex | 14 ++++++++++++-- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 90569d6e..8b98b11e 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -210,8 +210,8 @@ defmodule Philomena.Comments do def report_non_approved(comment) do Reports.create_system_report( - comment.id, "Comment", + comment.id, "Approval", "Comment contains externally-embedded images and has been flagged for review." ) diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index e9ed7500..46a3ce8f 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -236,8 +236,8 @@ defmodule Philomena.Conversations do def report_non_approved(id) do Reports.create_system_report( - id, "Conversation", + id, "Approval", "PM contains externally-embedded images and has been flagged for review." ) diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 5fea2e35..90bdd5b4 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -193,8 +193,8 @@ defmodule Philomena.Images do defp maybe_suggest_user_verification(%User{id: id, uploads_count: 5, verified: false}) do Reports.create_system_report( - id, "User", + id, "Verification", "User has uploaded enough approved images to be considered for verification." ) diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index fc339c97..bde21f16 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -114,8 +114,8 @@ defmodule Philomena.Posts do def report_non_approved(post) do Reports.create_system_report( - post.id, "Post", + post.id, "Approval", "Post contains externally-embedded images and has been flagged for review." ) diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 4f10391d..f39838d5 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -95,7 +95,17 @@ defmodule Philomena.Reports do update: [set: [open: false, state: "closed", admin_id: ^closing_user.id]] end - def create_system_report(reportable_id, reportable_type, category, reason) do + @doc """ + Automatically create a report with the given category and reason on the given + `reportable_id` and `reportable_type`. + + ## Examples + + iex> create_system_report("Comment", 1, "Other", "Custom report reason") + {:ok, %Report{}} + + """ + def create_system_report(reportable_type, reportable_id, category, reason) do attrs = %{ reason: reason, category: category @@ -109,7 +119,7 @@ defmodule Philomena.Reports do "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0" } - %Report{reportable_id: reportable_id, reportable_type: reportable_type} + %Report{reportable_type: reportable_type, reportable_id: reportable_id} |> Report.creation_changeset(attrs, attributes) |> Repo.insert() |> reindex_after_update() From c8410e7957f347f8c8bef528a905b3fe332b449b Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 20 Jul 2024 22:30:58 -0400 Subject: [PATCH 032/115] More documentation --- lib/philomena/reports.ex | 78 ++++++++++++++++--- .../plugs/admin_counters_plug.ex | 2 +- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index f39838d5..4ce7d868 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -12,6 +12,31 @@ defmodule Philomena.Reports do alias Philomena.IndexWorker alias Philomena.Polymorphic + @doc """ + Returns the current number of open reports. + + If the user is allowed to view reports, returns the current count. + If the user is not allowed to view reports, returns `nil`. + + ## Examples + + iex> count_reports(%User{}) + nil + + iex> count_reports(%User{role: "admin"}) + 4 + + """ + def count_open_reports(user) do + if Canada.Can.can?(user, :index, Report) do + Report + |> where(open: true) + |> Repo.aggregate(:count) + else + nil + end + end + @doc """ Returns the list of reports. @@ -173,6 +198,15 @@ defmodule Philomena.Reports do Report.changeset(report, %{}) end + @doc """ + Marks the report as claimed by the given user. + + ## Example + + iex> claim_report(%Report{}, %User{}) + {:ok, %Report{}} + + """ def claim_report(%Report{} = report, user) do report |> Report.claim_changeset(user) @@ -180,6 +214,15 @@ defmodule Philomena.Reports do |> reindex_after_update() end + @doc """ + Marks the report as unclaimed. + + ## Example + + iex> unclaim_report(%Report{}) + {:ok, %Report{}} + + """ def unclaim_report(%Report{} = report) do report |> Report.unclaim_changeset() @@ -187,6 +230,15 @@ defmodule Philomena.Reports do |> reindex_after_update() end + @doc """ + Marks the report as closed by the given user. + + ## Example + + iex> close_report(%Report{}, %User{}) + {:ok, %Report{}} + + """ def close_report(%Report{} = report, user) do report |> Report.close_changeset(user) @@ -194,6 +246,15 @@ defmodule Philomena.Reports do |> reindex_after_update() end + @doc """ + Reindex all reports where the user or admin has `old_name`. + + ## Example + + iex> user_name_reindex("Administrator", "Administrator2") + {:ok, %Req.Response{}} + + """ def user_name_reindex(old_name, new_name) do data = ReportIndex.user_name_update_by_query(old_name, new_name) @@ -210,18 +271,25 @@ defmodule Philomena.Reports do result end + @doc """ + Callback for post-transaction update. + + See `close_report_query/2` for more information and example. + """ def reindex_reports(report_ids) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", report_ids]) report_ids end + @doc false def reindex_report(%Report{} = report) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", [report.id]]) report end + @doc false def perform_reindex(column, condition) do Report |> where([r], field(r, ^column) in ^condition) @@ -230,14 +298,4 @@ defmodule Philomena.Reports do |> Polymorphic.load_polymorphic(reportable: [reportable_id: :reportable_type]) |> Enum.map(&Search.index_document(&1, Report)) end - - def count_reports(user) do - if Canada.Can.can?(user, :index, Report) do - Report - |> where(open: true) - |> Repo.aggregate(:count, :id) - else - nil - end - end end diff --git a/lib/philomena_web/plugs/admin_counters_plug.ex b/lib/philomena_web/plugs/admin_counters_plug.ex index 9e01a559..bf271c0e 100644 --- a/lib/philomena_web/plugs/admin_counters_plug.ex +++ b/lib/philomena_web/plugs/admin_counters_plug.ex @@ -34,7 +34,7 @@ defmodule PhilomenaWeb.AdminCountersPlug do defp maybe_assign_admin_metrics(conn, user, true) do pending_approvals = Images.count_pending_approvals(user) duplicate_reports = DuplicateReports.count_duplicate_reports(user) - reports = Reports.count_reports(user) + reports = Reports.count_open_reports(user) artist_links = ArtistLinks.count_artist_links(user) dnps = DnpEntries.count_dnp_entries(user) From c898b83e6dec5ee75a8ff96e980ad90c4a3edf61 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 20 Jul 2024 22:40:57 -0400 Subject: [PATCH 033/115] Reverse order of type and id arguments --- lib/philomena/comments.ex | 7 +++---- lib/philomena/conversations.ex | 5 ++--- lib/philomena/images.ex | 5 ++--- lib/philomena/posts.ex | 7 +++---- lib/philomena/reports.ex | 12 ++++++------ .../controllers/conversation/report_controller.ex | 2 +- .../controllers/gallery/report_controller.ex | 2 +- .../controllers/image/comment/report_controller.ex | 2 +- .../controllers/image/report_controller.ex | 2 +- .../profile/commission/report_controller.ex | 2 +- .../controllers/profile/report_controller.ex | 2 +- lib/philomena_web/controllers/report_controller.ex | 4 ++-- .../controllers/topic/post/report_controller.ex | 2 +- 13 files changed, 25 insertions(+), 29 deletions(-) diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 8b98b11e..d3a819d9 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -144,7 +144,7 @@ defmodule Philomena.Comments do end def hide_comment(%Comment{} = comment, attrs, user) do - report_query = Reports.close_report_query("Comment", comment.id, user) + report_query = Reports.close_report_query({"Comment", comment.id}, user) comment = Comment.hide_changeset(comment, attrs, user) Multi.new() @@ -185,7 +185,7 @@ defmodule Philomena.Comments do end def approve_comment(%Comment{} = comment, user) do - report_query = Reports.close_report_query("Comment", comment.id, user) + report_query = Reports.close_report_query({"Comment", comment.id}, user) comment = Comment.approve_changeset(comment) Multi.new() @@ -210,8 +210,7 @@ defmodule Philomena.Comments do def report_non_approved(comment) do Reports.create_system_report( - "Comment", - comment.id, + {"Comment", comment.id}, "Approval", "Comment contains externally-embedded images and has been flagged for review." ) diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index 46a3ce8f..82a2e495 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -208,7 +208,7 @@ defmodule Philomena.Conversations do end def approve_conversation_message(message, user) do - reports_query = Reports.close_report_query("Conversation", message.conversation_id, user) + reports_query = Reports.close_report_query({"Conversation", message.conversation_id}, user) message_query = message @@ -236,8 +236,7 @@ defmodule Philomena.Conversations do def report_non_approved(id) do Reports.create_system_report( - "Conversation", - id, + {"Conversation", id}, "Approval", "PM contains externally-embedded images and has been flagged for review." ) diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 90bdd5b4..605c6f0a 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -193,8 +193,7 @@ defmodule Philomena.Images do defp maybe_suggest_user_verification(%User{id: id, uploads_count: 5, verified: false}) do Reports.create_system_report( - "User", - id, + {"User", id}, "Verification", "User has uploaded enough approved images to be considered for verification." ) @@ -577,7 +576,7 @@ defmodule Philomena.Images do end defp hide_image_multi(changeset, image, user, multi) do - report_query = Reports.close_report_query("Image", image.id, user) + report_query = Reports.close_report_query({"Image", image.id}, user) galleries = Gallery diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index bde21f16..723ea6b6 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -114,8 +114,7 @@ defmodule Philomena.Posts do def report_non_approved(post) do Reports.create_system_report( - "Post", - post.id, + {"Post", post.id}, "Approval", "Post contains externally-embedded images and has been flagged for review." ) @@ -203,7 +202,7 @@ defmodule Philomena.Posts do end def hide_post(%Post{} = post, attrs, user) do - report_query = Reports.close_report_query("Post", post.id, user) + report_query = Reports.close_report_query({"Post", post.id}, user) topics = Topic @@ -250,7 +249,7 @@ defmodule Philomena.Posts do end def approve_post(%Post{} = post, user) do - report_query = Reports.close_report_query("Post", post.id, user) + report_query = Reports.close_report_query({"Post", post.id}, user) post = Post.approve_changeset(post) Multi.new() diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 4ce7d868..5cf508dd 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -78,8 +78,8 @@ defmodule Philomena.Reports do {:error, %Ecto.Changeset{}} """ - def create_report(reportable_id, reportable_type, attribution, attrs \\ %{}) do - %Report{reportable_id: reportable_id, reportable_type: reportable_type} + def create_report({reportable_type, reportable_id} = _type_and_id, attribution, attrs \\ %{}) do + %Report{reportable_type: reportable_type, reportable_id: reportable_id} |> Report.creation_changeset(attrs, attribution) |> Repo.insert() |> reindex_after_update() @@ -92,7 +92,7 @@ defmodule Philomena.Reports do Because this is only a query due to the limitations of `m:Ecto.Multi`, this must be coupled with an associated call to `reindex_reports/1` to operate correctly, e.g.: - report_query = Reports.close_system_report_query("Image", image.id, user) + report_query = Reports.close_system_report_query({"Image", image.id}, user) Multi.new() |> Multi.update_all(:reports, report_query, []) @@ -113,7 +113,7 @@ defmodule Philomena.Reports do #Ecto.Query<...> """ - def close_report_query(reportable_type, reportable_id, closing_user) do + def close_report_query({reportable_type, reportable_id} = _type_and_id, closing_user) do from r in Report, where: r.reportable_type == ^reportable_type and r.reportable_id == ^reportable_id, select: r.id, @@ -126,11 +126,11 @@ defmodule Philomena.Reports do ## Examples - iex> create_system_report("Comment", 1, "Other", "Custom report reason") + iex> create_system_report({"Comment", 1}, "Other", "Custom report reason") {:ok, %Report{}} """ - def create_system_report(reportable_type, reportable_id, category, reason) do + def create_system_report({reportable_type, reportable_id} = _type_and_id, category, reason) do attrs = %{ reason: reason, category: category diff --git a/lib/philomena_web/controllers/conversation/report_controller.ex b/lib/philomena_web/controllers/conversation/report_controller.ex index dab81482..0f5736ca 100644 --- a/lib/philomena_web/controllers/conversation/report_controller.ex +++ b/lib/philomena_web/controllers/conversation/report_controller.ex @@ -42,6 +42,6 @@ defmodule PhilomenaWeb.Conversation.ReportController do conversation = conn.assigns.conversation action = ~p"/conversations/#{conversation}/reports" - ReportController.create(conn, action, conversation, "Conversation", params) + ReportController.create(conn, action, "Conversation", conversation, params) end end diff --git a/lib/philomena_web/controllers/gallery/report_controller.ex b/lib/philomena_web/controllers/gallery/report_controller.ex index 3d4b5fd5..c5d8b0a2 100644 --- a/lib/philomena_web/controllers/gallery/report_controller.ex +++ b/lib/philomena_web/controllers/gallery/report_controller.ex @@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Gallery.ReportController do gallery = conn.assigns.gallery action = ~p"/galleries/#{gallery}/reports" - ReportController.create(conn, action, gallery, "Gallery", params) + ReportController.create(conn, action, "Gallery", gallery, params) end end diff --git a/lib/philomena_web/controllers/image/comment/report_controller.ex b/lib/philomena_web/controllers/image/comment/report_controller.ex index d957abbd..cb2f0b98 100644 --- a/lib/philomena_web/controllers/image/comment/report_controller.ex +++ b/lib/philomena_web/controllers/image/comment/report_controller.ex @@ -44,6 +44,6 @@ defmodule PhilomenaWeb.Image.Comment.ReportController do comment = conn.assigns.comment action = ~p"/images/#{comment.image}/comments/#{comment}/reports" - ReportController.create(conn, action, comment, "Comment", params) + ReportController.create(conn, action, "Comment", comment, params) end end diff --git a/lib/philomena_web/controllers/image/report_controller.ex b/lib/philomena_web/controllers/image/report_controller.ex index 6956832e..c00cee3d 100644 --- a/lib/philomena_web/controllers/image/report_controller.ex +++ b/lib/philomena_web/controllers/image/report_controller.ex @@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Image.ReportController do image = conn.assigns.image action = ~p"/images/#{image}/reports" - ReportController.create(conn, action, image, "Image", params) + ReportController.create(conn, action, "Image", image, params) end end diff --git a/lib/philomena_web/controllers/profile/commission/report_controller.ex b/lib/philomena_web/controllers/profile/commission/report_controller.ex index 0ad943ef..9fe29308 100644 --- a/lib/philomena_web/controllers/profile/commission/report_controller.ex +++ b/lib/philomena_web/controllers/profile/commission/report_controller.ex @@ -53,7 +53,7 @@ defmodule PhilomenaWeb.Profile.Commission.ReportController do commission = conn.assigns.user.commission action = ~p"/profiles/#{user}/commission/reports" - ReportController.create(conn, action, commission, "Commission", params) + ReportController.create(conn, action, "Commission", commission, params) end defp ensure_commission(conn, _opts) do diff --git a/lib/philomena_web/controllers/profile/report_controller.ex b/lib/philomena_web/controllers/profile/report_controller.ex index 80a68895..b57c3a64 100644 --- a/lib/philomena_web/controllers/profile/report_controller.ex +++ b/lib/philomena_web/controllers/profile/report_controller.ex @@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Profile.ReportController do user = conn.assigns.user action = ~p"/profiles/#{user}/reports" - ReportController.create(conn, action, user, "User", params) + ReportController.create(conn, action, "User", user, params) end end diff --git a/lib/philomena_web/controllers/report_controller.ex b/lib/philomena_web/controllers/report_controller.ex index 7860a32e..803f224e 100644 --- a/lib/philomena_web/controllers/report_controller.ex +++ b/lib/philomena_web/controllers/report_controller.ex @@ -33,7 +33,7 @@ defmodule PhilomenaWeb.ReportController do # plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] # plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - def create(conn, action, reportable, reportable_type, %{"report" => report_params}) do + def create(conn, action, reportable_type, reportable, %{"report" => report_params}) do attribution = conn.assigns.attributes case too_many_reports?(conn) do @@ -46,7 +46,7 @@ defmodule PhilomenaWeb.ReportController do |> redirect(to: "/") _falsy -> - case Reports.create_report(reportable.id, reportable_type, attribution, report_params) do + case Reports.create_report({reportable_type, reportable.id}, attribution, report_params) do {:ok, _report} -> conn |> put_flash( diff --git a/lib/philomena_web/controllers/topic/post/report_controller.ex b/lib/philomena_web/controllers/topic/post/report_controller.ex index f09df511..b93ab225 100644 --- a/lib/philomena_web/controllers/topic/post/report_controller.ex +++ b/lib/philomena_web/controllers/topic/post/report_controller.ex @@ -42,6 +42,6 @@ defmodule PhilomenaWeb.Topic.Post.ReportController do post = conn.assigns.post action = ~p"/forums/#{topic.forum}/topics/#{topic}/posts/#{post}/reports" - ReportController.create(conn, action, post, "Post", params) + ReportController.create(conn, action, "Post", post, params) end end From 5def18e5f0bb8921310de8acb908f11d3209abac Mon Sep 17 00:00:00 2001 From: mdashlw Date: Sat, 20 Jul 2024 22:04:14 -0700 Subject: [PATCH 034/115] fix(Philomena.DuplicateReports.SearchQuery): validate_required in image_changeset --- lib/philomena/duplicate_reports/search_query.ex | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/philomena/duplicate_reports/search_query.ex b/lib/philomena/duplicate_reports/search_query.ex index bc922077..23525c72 100644 --- a/lib/philomena/duplicate_reports/search_query.ex +++ b/lib/philomena/duplicate_reports/search_query.ex @@ -37,6 +37,16 @@ defmodule Philomena.DuplicateReports.SearchQuery do :image_aspect_ratio, :uploaded_image ]) + |> validate_required([ + :image_width, + :image_height, + :image_format, + :image_duration, + :image_mime_type, + :image_is_animated, + :image_aspect_ratio, + :uploaded_image + ]) |> validate_number(:image_width, greater_than: 0) |> validate_number(:image_height, greater_than: 0) |> validate_inclusion( From ddf166d1a65b8638cc0cd639ba032841be35ab77 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 21 Jul 2024 17:28:11 -0400 Subject: [PATCH 035/115] Fixup --- lib/philomena_web/controllers/admin/report_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/philomena_web/controllers/admin/report_controller.ex b/lib/philomena_web/controllers/admin/report_controller.ex index 3dc773ea..4dfcf505 100644 --- a/lib/philomena_web/controllers/admin/report_controller.ex +++ b/lib/philomena_web/controllers/admin/report_controller.ex @@ -6,6 +6,7 @@ defmodule PhilomenaWeb.Admin.ReportController do alias Philomena.Reports.Report alias Philomena.Reports.Query alias Philomena.Polymorphic + alias Philomena.ModNotes.ModNote alias Philomena.ModNotes alias Philomena.Repo import Ecto.Query From f6c511ce486a815bc192207e4c3901a5ba7ca60d Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 21 Jul 2024 19:12:41 -0400 Subject: [PATCH 036/115] Order detected duplicates based on L2 distance from query point --- lib/philomena/duplicate_reports.ex | 10 ++++++++++ lib/philomena/duplicate_reports/power.ex | 9 +++++++++ 2 files changed, 19 insertions(+) create mode 100644 lib/philomena/duplicate_reports/power.ex diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index 5f84c9da..c6cb2c55 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -3,7 +3,9 @@ defmodule Philomena.DuplicateReports do The DuplicateReports context. """ + import Philomena.DuplicateReports.Power import Ecto.Query, warn: false + alias Ecto.Multi alias Philomena.Repo @@ -46,6 +48,14 @@ defmodule Philomena.DuplicateReports do where: i.image_aspect_ratio >= ^(aspect_ratio - aspect_dist) and i.image_aspect_ratio <= ^(aspect_ratio + aspect_dist), + order_by: [ + asc: + power(it.nw - ^intensities.nw, 2) + + power(it.ne - ^intensities.ne, 2) + + power(it.sw - ^intensities.sw, 2) + + power(it.se - ^intensities.se, 2) + + power(i.image_aspect_ratio - ^aspect_ratio, 2) + ], limit: ^limit end diff --git a/lib/philomena/duplicate_reports/power.ex b/lib/philomena/duplicate_reports/power.ex new file mode 100644 index 00000000..32f1bc1c --- /dev/null +++ b/lib/philomena/duplicate_reports/power.ex @@ -0,0 +1,9 @@ +defmodule Philomena.DuplicateReports.Power do + @moduledoc false + + defmacro power(left, right) do + quote do + fragment("power(?, ?)", unquote(left), unquote(right)) + end + end +end From a9077f4bf47a6e98c40db41311dd2e5aebe3f4c0 Mon Sep 17 00:00:00 2001 From: mdashlw Date: Sun, 21 Jul 2024 18:54:01 -0700 Subject: [PATCH 037/115] fix: :uploaded_image instead of :image SearchQuery doesn't have :image so I don't think this error_tag would ever be reached --- lib/philomena_web/templates/search/reverse/index.html.slime | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime index 83dd72a7..97d88686 100644 --- a/lib/philomena_web/templates/search/reverse/index.html.slime +++ b/lib/philomena_web/templates/search/reverse/index.html.slime @@ -13,7 +13,7 @@ h1 Reverse Search p Upload a file from your computer, or provide a link to the page containing the image and click Fetch. .field = file_input f, :image, class: "input js-scraper" - = error_tag f, :image + = error_tag f, :uploaded_image = error_tag f, :image_width = error_tag f, :image_height = error_tag f, :image_mime_type @@ -40,7 +40,7 @@ h1 Reverse Search = cond do - is_nil(@images) -> - + - Enum.any?(@images) -> h2 Results @@ -49,7 +49,7 @@ h1 Reverse Search th   th Image th   - + = for match <- @images do tr th From 9ca1a50a0af6667e9aaf1dc90a6a5353c0cc8508 Mon Sep 17 00:00:00 2001 From: mdashlw Date: Sun, 21 Jul 2024 19:05:35 -0700 Subject: [PATCH 038/115] feat: use keyCode instead of key --- assets/js/shortcuts.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts index 682863ef..3de21c89 100644 --- a/assets/js/shortcuts.ts +++ b/assets/js/shortcuts.ts @@ -4,7 +4,7 @@ import { $ } from './utils/dom'; -type ShortcutKeyMap = Record void>; +type ShortcutKeyMap = Record void>; function getHover(): string | null { const thumbBoxHover = $('.media-box:hover'); @@ -50,19 +50,19 @@ function isOK(event: KeyboardEvent): boolean { /* eslint-disable prettier/prettier */ const keyCodes: ShortcutKeyMap = { - j() { click('.js-prev'); }, // J - go to previous image - i() { click('.js-up'); }, // I - go to index page - k() { click('.js-next'); }, // K - go to next image - r() { click('.js-rand'); }, // R - go to random image - s() { click('.js-source-link'); }, // S - go to image source - l() { click('.js-tag-sauce-toggle'); }, // L - edit tags - o() { openFullView(); }, // O - open original - v() { openFullViewNewTab(); }, // V - open original in a new tab - f() { + 74() { click('.js-prev'); }, // J - go to previous image + 73() { click('.js-up'); }, // I - go to index page + 75() { click('.js-next'); }, // K - go to next image + 82() { click('.js-rand'); }, // R - go to random image + 83() { click('.js-source-link'); }, // S - go to image source + 76() { click('.js-tag-sauce-toggle'); }, // L - edit tags + 79() { openFullView(); }, // O - open original + 86() { openFullViewNewTab(); }, // V - open original in a new tab + 70() { // F - favourite image click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` : '.block__header a.interaction--fave'); }, - u() { + 85() { // U - upvote image click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` : '.block__header a.interaction--upvote'); }, @@ -72,8 +72,8 @@ const keyCodes: ShortcutKeyMap = { export function listenForKeys() { document.addEventListener('keydown', (event: KeyboardEvent) => { - if (isOK(event) && keyCodes[event.key]) { - keyCodes[event.key](); + if (isOK(event) && keyCodes[event.keyCode]) { + keyCodes[event.keyCode](); event.preventDefault(); } }); From b442d983b05f8afe7e57148240477d37e99b562d Mon Sep 17 00:00:00 2001 From: mdashlw Date: Sun, 21 Jul 2024 19:08:38 -0700 Subject: [PATCH 039/115] feat: use keyCode for markdown shortcuts --- assets/js/markdowntoolbar.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/assets/js/markdowntoolbar.js b/assets/js/markdowntoolbar.js index 534388f6..05c8eb8e 100644 --- a/assets/js/markdowntoolbar.js +++ b/assets/js/markdowntoolbar.js @@ -7,19 +7,19 @@ import { $, $$ } from './utils/dom'; const markdownSyntax = { bold: { action: wrapSelection, - options: { prefix: '**', shortcutKey: 'b' }, + options: { prefix: '**', shortcutKeyCode: 66 }, }, italics: { action: wrapSelection, - options: { prefix: '*', shortcutKey: 'i' }, + options: { prefix: '*', shortcutKeyCode: 73 }, }, under: { action: wrapSelection, - options: { prefix: '__', shortcutKey: 'u' }, + options: { prefix: '__', shortcutKeyCode: 85 }, }, spoiler: { action: wrapSelection, - options: { prefix: '||', shortcutKey: 's' }, + options: { prefix: '||', shortcutKeyCode: 83 }, }, code: { action: wrapSelectionOrLines, @@ -29,7 +29,7 @@ const markdownSyntax = { prefixMultiline: '```\n', suffixMultiline: '\n```', singleWrap: true, - shortcutKey: 'e', + shortcutKeyCode: 69, }, }, strike: { @@ -50,11 +50,11 @@ const markdownSyntax = { }, link: { action: insertLink, - options: { shortcutKey: 'l' }, + options: { shortcutKeyCode: 76 }, }, image: { action: insertLink, - options: { image: true, shortcutKey: 'k' }, + options: { image: true, shortcutKeyCode: 75 }, }, escape: { action: escapeSelection, @@ -257,10 +257,10 @@ function shortcutHandler(event) { } const textarea = event.target, - key = event.key.toLowerCase(); + keyCode = event.keyCode; for (const id in markdownSyntax) { - if (key === markdownSyntax[id].options.shortcutKey) { + if (keyCode === markdownSyntax[id].options.shortcutKeyCode) { markdownSyntax[id].action(textarea, markdownSyntax[id].options); event.preventDefault(); } From 5a97388908c3def269592358c6374a5493ad3503 Mon Sep 17 00:00:00 2001 From: mdashlw Date: Tue, 23 Jul 2024 05:58:49 -0700 Subject: [PATCH 040/115] feat: add image_orig_size --- index/images.mk | 1 + lib/philomena/images/image.ex | 3 +++ lib/philomena/images/query.ex | 2 +- lib/philomena/images/search_index.ex | 2 ++ lib/philomena_media/uploader.ex | 3 +++ lib/philomena_web/views/api/json/image_view.ex | 1 + .../20240723122759_add_images_orig_size.exs | 9 +++++++++ priv/repo/structure.sql | 11 ++++++++--- 8 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20240723122759_add_images_orig_size.exs diff --git a/index/images.mk b/index/images.mk index 2ed13496..a96f446a 100644 --- a/index/images.mk +++ b/index/images.mk @@ -42,6 +42,7 @@ metadata: image_search_json 'processed', processed, 'score', score, 'size', image_size, + 'orig_size', image_orig_size, 'sha512_hash', image_sha512_hash, 'thumbnails_generated', thumbnails_generated, 'updated_at', updated_at, diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 3e9c889a..ee03b651 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -51,6 +51,7 @@ defmodule Philomena.Images.Image do field :image_width, :integer field :image_height, :integer field :image_size, :integer + field :image_orig_size, :integer field :image_format, :string field :image_mime_type, :string field :image_aspect_ratio, :float @@ -137,6 +138,7 @@ defmodule Philomena.Images.Image do :image_width, :image_height, :image_size, + :image_orig_size, :image_format, :image_mime_type, :image_aspect_ratio, @@ -152,6 +154,7 @@ defmodule Philomena.Images.Image do :image_width, :image_height, :image_size, + :image_orig_size, :image_format, :image_mime_type, :image_aspect_ratio, diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex index 74d1b835..d63fa789 100644 --- a/lib/philomena/images/query.ex +++ b/lib/philomena/images/query.ex @@ -84,7 +84,7 @@ defmodule Philomena.Images.Query do defp anonymous_fields do [ int_fields: - ~W(id width height score upvotes downvotes faves uploader_id faved_by_id pixels size comment_count source_count tag_count) ++ + ~W(id width height score upvotes downvotes faves uploader_id faved_by_id pixels size orig_size comment_count source_count tag_count) ++ tag_count_fields(), float_fields: ~W(aspect_ratio wilson_score duration), date_fields: ~W(created_at updated_at first_seen_at), diff --git a/lib/philomena/images/search_index.ex b/lib/philomena/images/search_index.ex index 2d9265b5..35241ccd 100644 --- a/lib/philomena/images/search_index.ex +++ b/lib/philomena/images/search_index.ex @@ -54,6 +54,7 @@ defmodule Philomena.Images.SearchIndex do processed: %{type: "boolean"}, score: %{type: "integer"}, size: %{type: "integer"}, + orig_size: %{type: "integer"}, sha512_hash: %{type: "keyword"}, source_url: %{type: "keyword"}, source_count: %{type: "integer"}, @@ -117,6 +118,7 @@ defmodule Philomena.Images.SearchIndex do height: image.image_height, pixels: image.image_width * image.image_height, size: image.image_size, + orig_size: image.image_orig_size, animated: image.image_is_animated, duration: if(image.image_is_animated, do: image.image_duration, else: 0), tag_count: length(image.tags), diff --git a/lib/philomena_media/uploader.ex b/lib/philomena_media/uploader.ex index 3df61945..f15e1b9c 100644 --- a/lib/philomena_media/uploader.ex +++ b/lib/philomena_media/uploader.ex @@ -130,6 +130,7 @@ defmodule PhilomenaMedia.Uploader do * `width` (integer) - the width of the file * `height` (integer) - the height of the file * `size` (integer) - the size of the file, in bytes + * `orig_size` (integer) - the size of the file, in bytes * `format` (String) - the file extension, one of `~w(gif jpg png svg webm)`, determined by reading the file * `mime_type` (String) - the file's sniffed MIME type, determined by reading the file * `duration` (float) - the duration of the media file @@ -148,6 +149,7 @@ defmodule PhilomenaMedia.Uploader do :foo_width, :foo_height, :foo_size, + :foo_orig_size, :foo_format, :foo_mime_type, :foo_duration, @@ -221,6 +223,7 @@ defmodule PhilomenaMedia.Uploader do "width" => analysis.width, "height" => analysis.height, "size" => analysis.size, + "orig_size" => analysis.size, "format" => analysis.extension, "mime_type" => analysis.mime_type, "duration" => analysis.duration, diff --git a/lib/philomena_web/views/api/json/image_view.ex b/lib/philomena_web/views/api/json/image_view.ex index d6c8b951..f72a676e 100644 --- a/lib/philomena_web/views/api/json/image_view.ex +++ b/lib/philomena_web/views/api/json/image_view.ex @@ -60,6 +60,7 @@ defmodule PhilomenaWeb.Api.Json.ImageView do height: image.image_height, mime_type: image.image_mime_type, size: image.image_size, + orig_size: image.image_orig_size, duration: image.image_duration, animated: image.image_is_animated, format: image.image_format, diff --git a/priv/repo/migrations/20240723122759_add_images_orig_size.exs b/priv/repo/migrations/20240723122759_add_images_orig_size.exs new file mode 100644 index 00000000..e41ff7af --- /dev/null +++ b/priv/repo/migrations/20240723122759_add_images_orig_size.exs @@ -0,0 +1,9 @@ +defmodule Philomena.Repo.Migrations.AddImagesOrigSize do + use Ecto.Migration + + def change do + alter table("images") do + add :image_orig_size, :integer + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index ab137a7c..47cf9f04 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 14.1 --- Dumped by pg_dump version 14.1 +-- Dumped from database version 16.3 +-- Dumped by pg_dump version 16.3 SET statement_timeout = 0; SET lock_timeout = 0; @@ -953,7 +953,8 @@ CREATE TABLE public.images ( image_duration double precision, description character varying DEFAULT ''::character varying NOT NULL, scratchpad character varying, - approved boolean DEFAULT false + approved boolean DEFAULT false, + image_orig_size integer ); @@ -4996,6 +4997,9 @@ ALTER TABLE ONLY public.image_tag_locks ALTER TABLE ONLY public.moderation_logs ADD CONSTRAINT moderation_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- -- Name: source_changes source_changes_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5051,3 +5055,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20211009011024); INSERT INTO public."schema_migrations" (version) VALUES (20211107130226); INSERT INTO public."schema_migrations" (version) VALUES (20211219194836); INSERT INTO public."schema_migrations" (version) VALUES (20220321173359); +INSERT INTO public."schema_migrations" (version) VALUES (20240723122759); From 0a4bcf60ebe4813e3e1cb8a79b15cd2672570411 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 23 Jul 2024 20:29:41 -0400 Subject: [PATCH 041/115] Fixup --- lib/philomena/tag_changes/limits.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/philomena/tag_changes/limits.ex b/lib/philomena/tag_changes/limits.ex index 496ef2c2..f30d2d90 100644 --- a/lib/philomena/tag_changes/limits.ex +++ b/lib/philomena/tag_changes/limits.ex @@ -77,7 +77,7 @@ defmodule Philomena.TagChanges.Limits do defp check_limit(user, key, limit, additional) do if considered_for_limit?(user) do - amt = Redix.command!(:redix, ["GET", key]) || 0 + amt = String.to_integer(Redix.command!(:redix, ["GET", key]) || "0") amt + additional >= limit else false From 17a434aa36cf6f73f35f4804f1147388045770a8 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 14 Jul 2024 18:56:23 -0400 Subject: [PATCH 042/115] Preliminary cleanup for conversations module --- lib/philomena/conversations.ex | 323 ++++++++---------- lib/philomena/conversations/conversation.ex | 21 +- lib/philomena/conversations/message.ex | 1 + .../message/approve_controller.ex | 2 +- .../conversation/message_controller.ex | 6 +- .../controllers/conversation_controller.ex | 8 +- 6 files changed, 156 insertions(+), 205 deletions(-) diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index 82a2e495..1de8fe1c 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -6,24 +6,9 @@ defmodule Philomena.Conversations do import Ecto.Query, warn: false alias Ecto.Multi alias Philomena.Repo - alias Philomena.Reports alias Philomena.Conversations.Conversation - - @doc """ - Gets a single conversation. - - Raises `Ecto.NoResultsError` if the Conversation does not exist. - - ## Examples - - iex> get_conversation!(123) - %Conversation{} - - iex> get_conversation!(456) - ** (Ecto.NoResultsError) - - """ - def get_conversation!(id), do: Repo.get!(Conversation, id) + alias Philomena.Conversations.Message + alias Philomena.Reports @doc """ Creates a conversation. @@ -41,40 +26,14 @@ defmodule Philomena.Conversations do %Conversation{} |> Conversation.creation_changeset(from, attrs) |> Repo.insert() - end + |> case do + {:ok, conversation} -> + report_non_approved_message(hd(conversation.messages)) + {:ok, conversation} - @doc """ - Updates a conversation. - - ## Examples - - iex> update_conversation(conversation, %{field: new_value}) - {:ok, %Conversation{}} - - iex> update_conversation(conversation, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_conversation(%Conversation{} = conversation, attrs) do - conversation - |> Conversation.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Conversation. - - ## Examples - - iex> delete_conversation(conversation) - {:ok, %Conversation{}} - - iex> delete_conversation(conversation) - {:error, %Ecto.Changeset{}} - - """ - def delete_conversation(%Conversation{} = conversation) do - Repo.delete(conversation) + error -> + error + end end @doc """ @@ -90,6 +49,20 @@ defmodule Philomena.Conversations do Conversation.changeset(conversation, %{}) end + @doc """ + Returns the number of unread conversations for the given user. + + Conversations hidden by the given user are not counted. + + ## Examples + + iex> count_unread_conversations(user1) + 0 + + iex> count_unread_conversations(user2) + 7 + + """ def count_unread_conversations(user) do Conversation |> where( @@ -99,187 +72,171 @@ defmodule Philomena.Conversations do not ((c.to_id == ^user.id and c.to_hidden == true) or (c.from_id == ^user.id and c.from_hidden == true)) ) - |> Repo.aggregate(:count, :id) + |> Repo.aggregate(:count) end - def mark_conversation_read(conversation, user, read \\ true) - - def mark_conversation_read( - %Conversation{to_id: user_id, from_id: user_id} = conversation, - %{id: user_id}, - read - ) do - conversation - |> Conversation.read_changeset(%{to_read: read, from_read: read}) - |> Repo.update() - end - - def mark_conversation_read(%Conversation{to_id: user_id} = conversation, %{id: user_id}, read) do - conversation - |> Conversation.read_changeset(%{to_read: read}) - |> Repo.update() - end - - def mark_conversation_read(%Conversation{from_id: user_id} = conversation, %{id: user_id}, read) do - conversation - |> Conversation.read_changeset(%{from_read: read}) - |> Repo.update() - end - - def mark_conversation_read(_conversation, _user, _read), do: {:ok, nil} - - def mark_conversation_hidden(conversation, user, hidden \\ true) - - def mark_conversation_hidden( - %Conversation{to_id: user_id} = conversation, - %{id: user_id}, - hidden - ) do - conversation - |> Conversation.hidden_changeset(%{to_hidden: hidden}) - |> Repo.update() - end - - def mark_conversation_hidden( - %Conversation{from_id: user_id} = conversation, - %{id: user_id}, - hidden - ) do - conversation - |> Conversation.hidden_changeset(%{from_hidden: hidden}) - |> Repo.update() - end - - def mark_conversation_hidden(_conversation, _user, _read), do: {:ok, nil} - - alias Philomena.Conversations.Message - @doc """ - Gets a single message. - - Raises `Ecto.NoResultsError` if the Message does not exist. + Marks a conversation as read or unread from the perspective of the given user. ## Examples - iex> get_message!(123) - %Message{} + iex> mark_conversation_read(conversation, user, true) + {:ok, %Conversation{}} - iex> get_message!(456) - ** (Ecto.NoResultsError) + iex> mark_conversation_read(conversation, user, false) + {:ok, %Conversation{}} + + iex> mark_conversation_read(conversation, %User{}, true) + {:error, %Ecto.Changeset{}} """ - def get_message!(id), do: Repo.get!(Message, id) + def mark_conversation_read(%Conversation{} = conversation, user, read \\ true) do + changes = + %{} + |> put_conditional(:to_read, read, conversation.to_id == user.id) + |> put_conditional(:from_read, read, conversation.from_id == user.id) + + conversation + |> Conversation.read_changeset(changes) + |> Repo.update() + end @doc """ - Creates a message. + Marks a conversation as hidden or visible from the perspective of the given user. + + Hidden conversations are not shown in the list of conversations for the user, and + are not counted when retrieving the number of unread conversations. ## Examples - iex> create_message(%{field: value}) + iex> mark_conversation_hidden(conversation, user, true) + {:ok, %Conversation{}} + + iex> mark_conversation_hidden(conversation, user, false) + {:ok, %Conversation{}} + + iex> mark_conversation_hidden(conversation, %User{}, true) + {:error, %Ecto.Changeset{}} + + """ + def mark_conversation_hidden(%Conversation{} = conversation, user, hidden \\ true) do + changes = + %{} + |> put_conditional(:to_hidden, hidden, conversation.to_id == user.id) + |> put_conditional(:from_hidden, hidden, conversation.from_id == user.id) + + conversation + |> Conversation.hidden_changeset(changes) + |> Repo.update() + end + + defp put_conditional(map, key, value, condition) do + if condition do + Map.put(map, key, value) + else + map + end + end + + @doc """ + Creates a message within a conversation. + + ## Examples + + iex> create_message(%Conversation{}, %User{}, %{field: value}) {:ok, %Message{}} - iex> create_message(%{field: bad_value}) + iex> create_message(%Conversation{}, %User{}, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ def create_message(conversation, user, attrs \\ %{}) do - message = - Ecto.build_assoc(conversation, :messages) + message_changeset = + conversation + |> Ecto.build_assoc(:messages) |> Message.creation_changeset(attrs, user) - show_as_read = - case message do - %{changes: %{approved: true}} -> false - _ -> true - end - - conversation_query = - Conversation - |> where(id: ^conversation.id) - - now = DateTime.utc_now() + conversation_changeset = + Conversation.new_message_changeset(conversation) Multi.new() - |> Multi.insert(:message, message) - |> Multi.update_all(:conversation, conversation_query, - set: [from_read: show_as_read, to_read: show_as_read, last_message_at: now] - ) - |> Repo.transaction() - end - - def approve_conversation_message(message, user) do - reports_query = Reports.close_report_query({"Conversation", message.conversation_id}, user) - - message_query = - message - |> Message.approve_changeset() - - conversation_query = - Conversation - |> where(id: ^message.conversation_id) - - Multi.new() - |> Multi.update(:message, message_query) - |> Multi.update_all(:conversation, conversation_query, set: [to_read: false]) - |> Multi.update_all(:reports, reports_query, []) + |> Multi.insert(:message, message_changeset) + |> Multi.update(:conversation, conversation_changeset) |> Repo.transaction() |> case do - {:ok, %{reports: {_count, reports}} = result} -> - Reports.reindex_reports(reports) + {:ok, %{message: message}} -> + report_non_approved_message(message) + {:ok, message} - {:ok, result} - - error -> - error + _error -> + {:error, message_changeset} end end - def report_non_approved(id) do - Reports.create_system_report( - {"Conversation", id}, - "Approval", - "PM contains externally-embedded images and has been flagged for review." - ) - end - - def set_as_read(conversation) do - conversation - |> Conversation.to_read_changeset() - |> Repo.update() - end - @doc """ - Updates a message. + Approves a previously-posted message which was not approved at post time. ## Examples - iex> update_message(message, %{field: new_value}) + iex> approve_message(%Message{}, %User{}) {:ok, %Message{}} - iex> update_message(message, %{field: bad_value}) + iex> approve_message(%Message{}, %User{}) {:error, %Ecto.Changeset{}} """ - def update_message(%Message{} = message, attrs) do - message - |> Message.changeset(attrs) - |> Repo.update() + def approve_message(message, approving_user) do + message_changeset = Message.approve_changeset(message) + + conversation_update_query = + from c in Conversation, + where: c.id == ^message.conversation_id, + update: [set: [from_read: false, to_read: false]] + + reports_query = + Reports.close_report_query({"Conversation", message.conversation_id}, approving_user) + + Multi.new() + |> Multi.update(:message, message_changeset) + |> Multi.update_all(:conversation, conversation_update_query, []) + |> Multi.update_all(:reports, reports_query, []) + |> Repo.transaction() + |> case do + {:ok, %{reports: {_count, reports}, message: message}} -> + Reports.reindex_reports(reports) + + message + + _error -> + {:error, message_changeset} + end end @doc """ - Deletes a Message. + Generates a system report for an unapproved message. + + This is called by `create_conversation/2` and `create_message/3`, so it normally does not + need to be called explicitly. ## Examples - iex> delete_message(message) - {:ok, %Message{}} + iex> report_non_approved_message(%Message{approved: false}) + {:ok, %Report{}} - iex> delete_message(message) - {:error, %Ecto.Changeset{}} + iex> report_non_approved_message(%Message{approved: true}) + {:ok, nil} """ - def delete_message(%Message{} = message) do - Repo.delete(message) + def report_non_approved_message(message) do + if message.approved do + {:ok, nil} + else + Reports.create_system_report( + {"Conversation", message.conversation_id}, + "Approval", + "PM contains externally-embedded images and has been flagged for review." + ) + end end @doc """ diff --git a/lib/philomena/conversations/conversation.ex b/lib/philomena/conversations/conversation.ex index 77c5981d..d48aa0b9 100644 --- a/lib/philomena/conversations/conversation.ex +++ b/lib/philomena/conversations/conversation.ex @@ -32,19 +32,14 @@ defmodule Philomena.Conversations.Conversation do |> validate_required([]) end + @doc false def read_changeset(conversation, attrs) do - conversation - |> cast(attrs, [:from_read, :to_read]) - end - - def to_read_changeset(conversation) do - change(conversation) - |> put_change(:to_read, true) + cast(conversation, attrs, [:from_read, :to_read]) end + @doc false def hidden_changeset(conversation, attrs) do - conversation - |> cast(attrs, [:from_hidden, :to_hidden]) + cast(conversation, attrs, [:from_hidden, :to_hidden]) end @doc false @@ -61,6 +56,14 @@ defmodule Philomena.Conversations.Conversation do |> validate_length(:messages, is: 1) end + @doc false + def new_message_changeset(conversation) do + conversation + |> change(from_read: false) + |> change(to_read: false) + |> set_last_message() + end + defp set_slug(changeset) do changeset |> change(slug: Ecto.UUID.generate()) diff --git a/lib/philomena/conversations/message.ex b/lib/philomena/conversations/message.ex index a9e6fefd..4dced3af 100644 --- a/lib/philomena/conversations/message.ex +++ b/lib/philomena/conversations/message.ex @@ -33,6 +33,7 @@ defmodule Philomena.Conversations.Message do |> Approval.maybe_put_approval(user) end + @doc false def approve_changeset(message) do change(message, approved: true) end diff --git a/lib/philomena_web/controllers/conversation/message/approve_controller.ex b/lib/philomena_web/controllers/conversation/message/approve_controller.ex index 1693f432..fde13b1a 100644 --- a/lib/philomena_web/controllers/conversation/message/approve_controller.ex +++ b/lib/philomena_web/controllers/conversation/message/approve_controller.ex @@ -16,7 +16,7 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveController do message = conn.assigns.message {:ok, _message} = - Conversations.approve_conversation_message(message, conn.assigns.current_user) + Conversations.approve_message(message, conn.assigns.current_user) conn |> put_flash(:info, "Conversation message approved.") diff --git a/lib/philomena_web/controllers/conversation/message_controller.ex b/lib/philomena_web/controllers/conversation/message_controller.ex index 8bd44940..2d6d2ba2 100644 --- a/lib/philomena_web/controllers/conversation/message_controller.ex +++ b/lib/philomena_web/controllers/conversation/message_controller.ex @@ -20,11 +20,7 @@ defmodule PhilomenaWeb.Conversation.MessageController do user = conn.assigns.current_user case Conversations.create_message(conversation, user, message_params) do - {:ok, %{message: message}} -> - if not message.approved do - Conversations.report_non_approved(message.conversation_id) - end - + {:ok, _message} -> count = Message |> where(conversation_id: ^conversation.id) diff --git a/lib/philomena_web/controllers/conversation_controller.ex b/lib/philomena_web/controllers/conversation_controller.ex index 12784b42..f893fa1f 100644 --- a/lib/philomena_web/controllers/conversation_controller.ex +++ b/lib/philomena_web/controllers/conversation_controller.ex @@ -108,18 +108,12 @@ defmodule PhilomenaWeb.ConversationController do case Conversations.create_conversation(user, conversation_params) do {:ok, conversation} -> - if not hd(conversation.messages).approved do - Conversations.report_non_approved(conversation.id) - Conversations.set_as_read(conversation) - end - conn |> put_flash(:info, "Conversation successfully created.") |> redirect(to: ~p"/conversations/#{conversation}") {:error, changeset} -> - conn - |> render("new.html", changeset: changeset) + render(conn, "new.html", changeset: changeset) end end From 23332bec28ce958dc80c9c4cabce2e5301b73a03 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 14 Jul 2024 19:18:25 -0400 Subject: [PATCH 043/115] Move conversation controller functionality into context --- lib/philomena/conversations.ex | 183 ++++++++++++++---- lib/philomena/conversations/conversation.ex | 31 +-- lib/philomena/users.ex | 16 ++ .../conversation/message_controller.ex | 16 +- .../controllers/conversation_controller.ex | 77 ++------ .../templates/conversation/index.html.slime | 8 +- 6 files changed, 197 insertions(+), 134 deletions(-) diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index 1de8fe1c..aacf6b94 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -9,45 +9,7 @@ defmodule Philomena.Conversations do alias Philomena.Conversations.Conversation alias Philomena.Conversations.Message alias Philomena.Reports - - @doc """ - Creates a conversation. - - ## Examples - - iex> create_conversation(%{field: value}) - {:ok, %Conversation{}} - - iex> create_conversation(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_conversation(from, attrs \\ %{}) do - %Conversation{} - |> Conversation.creation_changeset(from, attrs) - |> Repo.insert() - |> case do - {:ok, conversation} -> - report_non_approved_message(hd(conversation.messages)) - {:ok, conversation} - - error -> - error - end - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking conversation changes. - - ## Examples - - iex> change_conversation(conversation) - %Ecto.Changeset{source: %Conversation{}} - - """ - def change_conversation(%Conversation{} = conversation) do - Conversation.changeset(conversation, %{}) - end + alias Philomena.Users @doc """ Returns the number of unread conversations for the given user. @@ -75,6 +37,96 @@ defmodule Philomena.Conversations do |> Repo.aggregate(:count) end + @doc """ + Returns a `m:Scrivener.Page` of conversations between the partner and the user. + + ## Examples + + iex> list_conversations_with("123", %User{}, page_size: 10) + %Scrivener.Page{} + + """ + def list_conversations_with(partner_id, user, pagination) do + query = + from c in Conversation, + where: + (c.from_id == ^partner_id and c.to_id == ^user.id) or + (c.to_id == ^partner_id and c.from_id == ^user.id) + + list_conversations(query, user, pagination) + end + + @doc """ + Returns a `m:Scrivener.Page` of conversations sent by or received from the user. + + ## Examples + + iex> list_conversations_with("123", %User{}, page_size: 10) + %Scrivener.Page{} + + """ + def list_conversations(queryable \\ Conversation, user, pagination) do + query = + from c in queryable, + as: :conversations, + where: + (c.from_id == ^user.id and not c.from_hidden) or + (c.to_id == ^user.id and not c.to_hidden), + inner_lateral_join: + cnt in subquery( + from m in Message, + where: m.conversation_id == parent_as(:conversations).id, + select: %{count: count()} + ), + on: true, + order_by: [desc: :last_message_at], + preload: [:to, :from], + select: %{c | message_count: cnt.count} + + Repo.paginate(query, pagination) + end + + @doc """ + Creates a conversation. + + ## Examples + + iex> create_conversation(from, to, %{field: value}) + {:ok, %Conversation{}} + + iex> create_conversation(from, to, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_conversation(from, attrs \\ %{}) do + to = Users.get_user_by_name(attrs["recipient"]) + + %Conversation{} + |> Conversation.creation_changeset(from, to, attrs) + |> Repo.insert() + |> case do + {:ok, conversation} -> + report_non_approved_message(hd(conversation.messages)) + {:ok, conversation} + + error -> + error + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking conversation changes. + + ## Examples + + iex> change_conversation(conversation) + %Ecto.Changeset{source: %Conversation{}} + + """ + def change_conversation(%Conversation{} = conversation) do + Conversation.changeset(conversation, %{}) + end + @doc """ Marks a conversation as read or unread from the perspective of the given user. @@ -138,6 +190,59 @@ defmodule Philomena.Conversations do end end + @doc """ + Returns the number of messages in the given conversation. + + ## Example + + iex> count_messages(%Conversation{}) + 3 + + """ + def count_messages(conversation) do + Message + |> where(conversation_id: ^conversation.id) + |> Repo.aggregate(:count) + end + + @doc """ + Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output + within a conversation. + + Messages are ordered by user message preference (`messages_newest_first`). + + When coerced to a list and rendered as Markdown, the result may look like: + + [ + {%Message{body: "hello *world*"}, "hello world"} + ] + + ## Example + + iex> list_messages(%Conversation{}, %User{}, & &1.body, page_size: 10) + %Scrivener.Page{} + + """ + def list_messages(conversation, user, collection_renderer, pagination) do + direction = + if user.messages_newest_first do + :desc + else + :asc + end + + query = + from m in Message, + where: m.conversation_id == ^conversation.id, + order_by: [{^direction, :created_at}], + preload: :from + + messages = Repo.paginate(query, pagination) + rendered = collection_renderer.(messages) + + put_in(messages.entries, Enum.zip(messages.entries, rendered)) + end + @doc """ Creates a message within a conversation. diff --git a/lib/philomena/conversations/conversation.ex b/lib/philomena/conversations/conversation.ex index d48aa0b9..b0122eb2 100644 --- a/lib/philomena/conversations/conversation.ex +++ b/lib/philomena/conversations/conversation.ex @@ -4,7 +4,6 @@ defmodule Philomena.Conversations.Conversation do alias Philomena.Users.User alias Philomena.Conversations.Message - alias Philomena.Repo @derive {Phoenix.Param, key: :slug} @@ -20,6 +19,8 @@ defmodule Philomena.Conversations.Conversation do field :from_hidden, :boolean, default: false field :slug, :string field :last_message_at, :utc_datetime + + field :message_count, :integer, virtual: true field :recipient, :string, virtual: true timestamps(inserted_at: :created_at, type: :utc_datetime) @@ -43,17 +44,17 @@ defmodule Philomena.Conversations.Conversation do end @doc false - def creation_changeset(conversation, from, attrs) do + def creation_changeset(conversation, from, to, attrs) do conversation - |> cast(attrs, [:title, :recipient]) - |> validate_required([:title, :recipient]) - |> validate_length(:title, max: 300, count: :bytes) + |> cast(attrs, [:title]) |> put_assoc(:from, from) - |> put_recipient() - |> set_slug() - |> set_last_message() + |> put_assoc(:to, to) + |> put_change(:slug, Ecto.UUID.generate()) |> cast_assoc(:messages, with: &Message.creation_changeset(&1, &2, from)) + |> set_last_message() |> validate_length(:messages, is: 1) + |> validate_length(:title, max: 300, count: :bytes) + |> validate_required([:title, :from, :to]) end @doc false @@ -64,21 +65,7 @@ defmodule Philomena.Conversations.Conversation do |> set_last_message() end - defp set_slug(changeset) do - changeset - |> change(slug: Ecto.UUID.generate()) - end - defp set_last_message(changeset) do change(changeset, last_message_at: DateTime.utc_now(:second)) end - - defp put_recipient(changeset) do - recipient = changeset |> get_field(:recipient) - user = Repo.get_by(User, name: recipient) - - changeset - |> put_change(:to, user) - |> validate_required(:to) - end end diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index 013b6173..575552aa 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -55,6 +55,22 @@ defmodule Philomena.Users do Repo.get_by(User, email: email) end + @doc """ + Gets a user by name. + + ## Examples + + iex> get_user_by_name("Administrator") + %User{} + + iex> get_user_by_name("nonexistent") + nil + + """ + def get_user_by_name(name) when is_binary(name) do + Repo.get_by(User, name: name) + end + @doc """ Gets a user by email and password. diff --git a/lib/philomena_web/controllers/conversation/message_controller.ex b/lib/philomena_web/controllers/conversation/message_controller.ex index 2d6d2ba2..f6f7fdd1 100644 --- a/lib/philomena_web/controllers/conversation/message_controller.ex +++ b/lib/philomena_web/controllers/conversation/message_controller.ex @@ -1,10 +1,8 @@ defmodule PhilomenaWeb.Conversation.MessageController do use PhilomenaWeb, :controller - alias Philomena.Conversations.{Conversation, Message} + alias Philomena.Conversations.Conversation alias Philomena.Conversations - alias Philomena.Repo - import Ecto.Query plug PhilomenaWeb.FilterBannedUsersPlug plug PhilomenaWeb.CanaryMapPlug, create: :show @@ -15,20 +13,16 @@ defmodule PhilomenaWeb.Conversation.MessageController do id_field: "slug", persisted: true + @page_size 25 + def create(conn, %{"message" => message_params}) do conversation = conn.assigns.conversation user = conn.assigns.current_user case Conversations.create_message(conversation, user, message_params) do {:ok, _message} -> - count = - Message - |> where(conversation_id: ^conversation.id) - |> Repo.aggregate(:count, :id) - - page = - Float.ceil(count / 25) - |> round() + count = Conversations.count_messages(conversation) + page = div(count + @page_size - 1, @page_size) conn |> put_flash(:info, "Message successfully sent.") diff --git a/lib/philomena_web/controllers/conversation_controller.ex b/lib/philomena_web/controllers/conversation_controller.ex index f893fa1f..0e4abdf0 100644 --- a/lib/philomena_web/controllers/conversation_controller.ex +++ b/lib/philomena_web/controllers/conversation_controller.ex @@ -4,8 +4,6 @@ defmodule PhilomenaWeb.ConversationController do alias PhilomenaWeb.NotificationCountPlug alias Philomena.{Conversations, Conversations.Conversation, Conversations.Message} alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Repo - import Ecto.Query plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] @@ -19,42 +17,17 @@ defmodule PhilomenaWeb.ConversationController do only: :show, preload: [:to, :from] - def index(conn, %{"with" => partner}) do + def index(conn, params) do user = conn.assigns.current_user - Conversation - |> where( - [c], - (c.from_id == ^user.id and c.to_id == ^partner and not c.from_hidden) or - (c.to_id == ^user.id and c.from_id == ^partner and not c.to_hidden) - ) - |> load_conversations(conn) - end - - def index(conn, _params) do - user = conn.assigns.current_user - - Conversation - |> where( - [c], - (c.from_id == ^user.id and not c.from_hidden) or (c.to_id == ^user.id and not c.to_hidden) - ) - |> load_conversations(conn) - end - - defp load_conversations(queryable, conn) do conversations = - queryable - |> join( - :inner_lateral, - [c], - _ in fragment("SELECT COUNT(*) FROM messages m WHERE m.conversation_id = ?", c.id), - on: true - ) - |> order_by(desc: :last_message_at) - |> preload([:to, :from]) - |> select([c, cnt], {c, cnt.count}) - |> Repo.paginate(conn.assigns.scrivener) + case params do + %{"with" => partner_id} -> + Conversations.list_conversations_with(partner_id, user, conn.assigns.scrivener) + + _ -> + Conversations.list_conversations(user, conn.assigns.scrivener) + end render(conn, "index.html", title: "Conversations", conversations: conversations) end @@ -62,27 +35,17 @@ defmodule PhilomenaWeb.ConversationController do def show(conn, _params) do conversation = conn.assigns.conversation user = conn.assigns.current_user - pref = load_direction(user) messages = - Message - |> where(conversation_id: ^conversation.id) - |> order_by([{^pref, :created_at}]) - |> preload([:from]) - |> Repo.paginate(conn.assigns.scrivener) + Conversations.list_messages( + conversation, + user, + &MarkdownRenderer.render_collection(&1, conn), + conn.assigns.scrivener + ) - rendered = - messages.entries - |> MarkdownRenderer.render_collection(conn) - - messages = %{messages | entries: Enum.zip(messages.entries, rendered)} - - changeset = - %Message{} - |> Conversations.change_message() - - conversation - |> Conversations.mark_conversation_read(user) + changeset = Conversations.change_message(%Message{}) + Conversations.mark_conversation_read(conversation, user) # Update the conversation ticker in the header conn = NotificationCountPlug.call(conn) @@ -96,9 +59,10 @@ defmodule PhilomenaWeb.ConversationController do end def new(conn, params) do - changeset = + conversation = %Conversation{recipient: params["recipient"], messages: [%Message{}]} - |> Conversations.change_conversation() + + changeset = Conversations.change_conversation(conversation) render(conn, "new.html", title: "New Conversation", changeset: changeset) end @@ -116,7 +80,4 @@ defmodule PhilomenaWeb.ConversationController do render(conn, "new.html", changeset: changeset) end end - - defp load_direction(%{messages_newest_first: false}), do: :asc - defp load_direction(_user), do: :desc end diff --git a/lib/philomena_web/templates/conversation/index.html.slime b/lib/philomena_web/templates/conversation/index.html.slime index 611610e0..a7c33f33 100644 --- a/lib/philomena_web/templates/conversation/index.html.slime +++ b/lib/philomena_web/templates/conversation/index.html.slime @@ -20,14 +20,14 @@ h1 My Conversations th.table--communication-list__stats With th.table--communication-list__options Options tbody - = for {c, count} <- @conversations do + = for c <- @conversations do tr class=conversation_class(@conn.assigns.current_user, c) td.table--communication-list__name => link c.title, to: ~p"/conversations/#{c}" .small-text.hide-mobile - => count - = pluralize("message", "messages", count) + => c.message_count + = pluralize("message", "messages", c.message_count) ' ; started = pretty_time(c.created_at) ' , last message @@ -36,7 +36,7 @@ h1 My Conversations td.table--communication-list__stats = render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: other_party(@current_user, c)}, conn: @conn td.table--communication-list__options - => link "Last message", to: last_message_path(c, count) + => link "Last message", to: last_message_path(c, c.message_count) ' • => link "Hide", to: ~p"/conversations/#{c}/hide", data: [method: "post"], data: [confirm: "Are you really, really sure?"] From 6ac5230b2e86fd59426566c756f2a55c9a5d88c8 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 7 Jul 2024 15:54:47 -0400 Subject: [PATCH 044/115] Migration --- .../20240728191353_new_notifications.exs | 109 +++++ priv/repo/structure.sql | 391 ++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 priv/repo/migrations/20240728191353_new_notifications.exs diff --git a/priv/repo/migrations/20240728191353_new_notifications.exs b/priv/repo/migrations/20240728191353_new_notifications.exs new file mode 100644 index 00000000..8ebfd890 --- /dev/null +++ b/priv/repo/migrations/20240728191353_new_notifications.exs @@ -0,0 +1,109 @@ +defmodule Philomena.Repo.Migrations.NewNotifications do + use Ecto.Migration + + @categories [ + channel_live: [channels: :channel_id], + forum_post: [topics: :topic_id, posts: :post_id], + forum_topic: [topics: :topic_id], + gallery_image: [galleries: :gallery_id], + image_comment: [images: :image_id, comments: :comment_id], + image_merge: [images: :target_id, images: :source_id] + ] + + def up do + for {category, refs} <- @categories do + create table("#{category}_notifications", primary_key: false) do + for {target_table_name, reference_name} <- refs do + add reference_name, references(target_table_name, on_delete: :delete_all), null: false + end + + add :user_id, references(:users, on_delete: :delete_all), null: false + timestamps(inserted_at: :created_at, type: :utc_datetime) + add :read, :boolean, default: false, null: false + end + + {_primary_table_name, primary_ref_name} = hd(refs) + create index("#{category}_notifications", [:user_id, primary_ref_name], unique: true) + create index("#{category}_notifications", [:user_id, "updated_at desc"]) + create index("#{category}_notifications", [:user_id, :read]) + + for {_target_table_name, reference_name} <- refs do + create index("#{category}_notifications", [reference_name]) + end + end + + insert_statements = + """ + insert into channel_live_notifications (channel_id, user_id, created_at, updated_at) + select n.actor_id, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Channel' + and exists(select 1 from channels c where c.id = n.actor_id) + and exists(select 1 from users u where u.id = un.user_id); + + insert into forum_post_notifications (topic_id, post_id, user_id, created_at, updated_at) + select n.actor_id, n.actor_child_id, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Topic' + and n.actor_child_type = 'Post' + and n.action = 'posted a new reply in' + and exists(select 1 from topics t where t.id = n.actor_id) + and exists(select 1 from posts p where p.id = n.actor_child_id) + and exists(select 1 from users u where u.id = un.user_id); + + insert into forum_topic_notifications (topic_id, user_id, created_at, updated_at) + select n.actor_id, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Topic' + and n.actor_child_type = 'Post' + and n.action <> 'posted a new reply in' + and exists(select 1 from topics t where t.id = n.actor_id) + and exists(select 1 from users u where u.id = un.user_id); + + insert into gallery_image_notifications (gallery_id, user_id, created_at, updated_at) + select n.actor_id, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Gallery' + and exists(select 1 from galleries g where g.id = n.actor_id) + and exists(select 1 from users u where u.id = un.user_id); + + insert into image_comment_notifications (image_id, comment_id, user_id, created_at, updated_at) + select n.actor_id, n.actor_child_id, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Image' + and n.actor_child_type = 'Comment' + and exists(select 1 from images i where i.id = n.actor_id) + and exists(select 1 from comments c where c.id = n.actor_child_id) + and exists(select 1 from users u where u.id = un.user_id); + + insert into image_merge_notifications (target_id, source_id, user_id, created_at, updated_at) + select n.actor_id, regexp_replace(n.action, '[a-z#]+', '', 'g')::bigint, un.user_id, n.created_at, n.updated_at + from unread_notifications un + join notifications n on un.notification_id = n.id + where n.actor_type = 'Image' + and n.actor_child_type is null + and exists(select 1 from images i where i.id = n.actor_id) + and exists(select 1 from images i where i.id = regexp_replace(n.action, '[a-z#]+', '', 'g')::integer) + and exists(select 1 from users u where u.id = un.user_id); + """ + + # These statements should not be run by the migration in production. + # Run them manually in psql instead. + if System.get_env("MIX_ENV") != "prod" do + for stmt <- String.split(insert_statements, "\n\n") do + execute(stmt) + end + end + end + + def down do + for {category, _refs} <- @categories do + drop table("#{category}_notifications") + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 47cf9f04..49678d33 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -198,6 +198,19 @@ CREATE SEQUENCE public.badges_id_seq ALTER SEQUENCE public.badges_id_seq OWNED BY public.badges.id; +-- +-- Name: channel_live_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_live_notifications ( + channel_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: channel_subscriptions; Type: TABLE; Schema: public; Owner: - -- @@ -620,6 +633,20 @@ CREATE SEQUENCE public.fingerprint_bans_id_seq ALTER SEQUENCE public.fingerprint_bans_id_seq OWNED BY public.fingerprint_bans.id; +-- +-- Name: forum_post_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.forum_post_notifications ( + topic_id bigint NOT NULL, + post_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: forum_subscriptions; Type: TABLE; Schema: public; Owner: - -- @@ -630,6 +657,19 @@ CREATE TABLE public.forum_subscriptions ( ); +-- +-- Name: forum_topic_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.forum_topic_notifications ( + topic_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: forums; Type: TABLE; Schema: public; Owner: - -- @@ -709,6 +749,19 @@ CREATE SEQUENCE public.galleries_id_seq ALTER SEQUENCE public.galleries_id_seq OWNED BY public.galleries.id; +-- +-- Name: gallery_image_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.gallery_image_notifications ( + gallery_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: gallery_interactions; Type: TABLE; Schema: public; Owner: - -- @@ -750,6 +803,20 @@ CREATE TABLE public.gallery_subscriptions ( ); +-- +-- Name: image_comment_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.image_comment_notifications ( + image_id bigint NOT NULL, + comment_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: image_faves; Type: TABLE; Schema: public; Owner: - -- @@ -837,6 +904,20 @@ CREATE SEQUENCE public.image_intensities_id_seq ALTER SEQUENCE public.image_intensities_id_seq OWNED BY public.image_intensities.id; +-- +-- Name: image_merge_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.image_merge_notifications ( + target_id bigint NOT NULL, + source_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + read boolean DEFAULT false NOT NULL +); + + -- -- Name: image_sources; Type: TABLE; Schema: public; Owner: - -- @@ -2894,6 +2975,160 @@ ALTER TABLE ONLY public.versions ADD CONSTRAINT versions_pkey PRIMARY KEY (id); +-- +-- Name: channel_live_notifications_channel_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_live_notifications_channel_id_index ON public.channel_live_notifications USING btree (channel_id); + + +-- +-- Name: channel_live_notifications_user_id_channel_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX channel_live_notifications_user_id_channel_id_index ON public.channel_live_notifications USING btree (user_id, channel_id); + + +-- +-- Name: channel_live_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_live_notifications_user_id_read_index ON public.channel_live_notifications USING btree (user_id, read); + + +-- +-- Name: channel_live_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_live_notifications_user_id_updated_at_desc_index ON public.channel_live_notifications USING btree (user_id, updated_at DESC); + + +-- +-- Name: forum_post_notifications_post_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_post_notifications_post_id_index ON public.forum_post_notifications USING btree (post_id); + + +-- +-- Name: forum_post_notifications_topic_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_post_notifications_topic_id_index ON public.forum_post_notifications USING btree (topic_id); + + +-- +-- Name: forum_post_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_post_notifications_user_id_read_index ON public.forum_post_notifications USING btree (user_id, read); + + +-- +-- Name: forum_post_notifications_user_id_topic_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX forum_post_notifications_user_id_topic_id_index ON public.forum_post_notifications USING btree (user_id, topic_id); + + +-- +-- Name: forum_post_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_post_notifications_user_id_updated_at_desc_index ON public.forum_post_notifications USING btree (user_id, updated_at DESC); + + +-- +-- Name: forum_topic_notifications_topic_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_topic_notifications_topic_id_index ON public.forum_topic_notifications USING btree (topic_id); + + +-- +-- Name: forum_topic_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_topic_notifications_user_id_read_index ON public.forum_topic_notifications USING btree (user_id, read); + + +-- +-- Name: forum_topic_notifications_user_id_topic_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX forum_topic_notifications_user_id_topic_id_index ON public.forum_topic_notifications USING btree (user_id, topic_id); + + +-- +-- Name: forum_topic_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forum_topic_notifications_user_id_updated_at_desc_index ON public.forum_topic_notifications USING btree (user_id, updated_at DESC); + + +-- +-- Name: gallery_image_notifications_gallery_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX gallery_image_notifications_gallery_id_index ON public.gallery_image_notifications USING btree (gallery_id); + + +-- +-- Name: gallery_image_notifications_user_id_gallery_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX gallery_image_notifications_user_id_gallery_id_index ON public.gallery_image_notifications USING btree (user_id, gallery_id); + + +-- +-- Name: gallery_image_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX gallery_image_notifications_user_id_read_index ON public.gallery_image_notifications USING btree (user_id, read); + + +-- +-- Name: gallery_image_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX gallery_image_notifications_user_id_updated_at_desc_index ON public.gallery_image_notifications USING btree (user_id, updated_at DESC); + + +-- +-- Name: image_comment_notifications_comment_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_comment_notifications_comment_id_index ON public.image_comment_notifications USING btree (comment_id); + + +-- +-- Name: image_comment_notifications_image_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_comment_notifications_image_id_index ON public.image_comment_notifications USING btree (image_id); + + +-- +-- Name: image_comment_notifications_user_id_image_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX image_comment_notifications_user_id_image_id_index ON public.image_comment_notifications USING btree (user_id, image_id); + + +-- +-- Name: image_comment_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_comment_notifications_user_id_read_index ON public.image_comment_notifications USING btree (user_id, read); + + +-- +-- Name: image_comment_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_comment_notifications_user_id_updated_at_desc_index ON public.image_comment_notifications USING btree (user_id, updated_at DESC); + + -- -- Name: image_intensities_index; Type: INDEX; Schema: public; Owner: - -- @@ -2901,6 +3136,41 @@ ALTER TABLE ONLY public.versions CREATE INDEX image_intensities_index ON public.image_intensities USING btree (nw, ne, sw, se); +-- +-- Name: image_merge_notifications_source_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_merge_notifications_source_id_index ON public.image_merge_notifications USING btree (source_id); + + +-- +-- Name: image_merge_notifications_target_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_merge_notifications_target_id_index ON public.image_merge_notifications USING btree (target_id); + + +-- +-- Name: image_merge_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_merge_notifications_user_id_read_index ON public.image_merge_notifications USING btree (user_id, read); + + +-- +-- Name: image_merge_notifications_user_id_target_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX image_merge_notifications_user_id_target_id_index ON public.image_merge_notifications USING btree (user_id, target_id); + + +-- +-- Name: image_merge_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX image_merge_notifications_user_id_updated_at_desc_index ON public.image_merge_notifications USING btree (user_id, updated_at DESC); + + -- -- Name: image_sources_image_id_source_index; Type: INDEX; Schema: public; Owner: - -- @@ -4175,6 +4445,22 @@ CREATE UNIQUE INDEX user_tokens_context_token_index ON public.user_tokens USING CREATE INDEX user_tokens_user_id_index ON public.user_tokens USING btree (user_id); +-- +-- Name: channel_live_notifications channel_live_notifications_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_live_notifications + ADD CONSTRAINT channel_live_notifications_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_live_notifications channel_live_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_live_notifications + ADD CONSTRAINT channel_live_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: channels fk_rails_021c624081; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -4967,6 +5253,110 @@ ALTER TABLE ONLY public.gallery_subscriptions ADD CONSTRAINT fk_rails_fa77f3cebe FOREIGN KEY (gallery_id) REFERENCES public.galleries(id) ON UPDATE CASCADE ON DELETE CASCADE; +-- +-- Name: forum_post_notifications forum_post_notifications_post_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forum_post_notifications + ADD CONSTRAINT forum_post_notifications_post_id_fkey FOREIGN KEY (post_id) REFERENCES public.posts(id) ON DELETE CASCADE; + + +-- +-- Name: forum_post_notifications forum_post_notifications_topic_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forum_post_notifications + ADD CONSTRAINT forum_post_notifications_topic_id_fkey FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE; + + +-- +-- Name: forum_post_notifications forum_post_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forum_post_notifications + ADD CONSTRAINT forum_post_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: forum_topic_notifications forum_topic_notifications_topic_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forum_topic_notifications + ADD CONSTRAINT forum_topic_notifications_topic_id_fkey FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE; + + +-- +-- Name: forum_topic_notifications forum_topic_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forum_topic_notifications + ADD CONSTRAINT forum_topic_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: gallery_image_notifications gallery_image_notifications_gallery_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.gallery_image_notifications + ADD CONSTRAINT gallery_image_notifications_gallery_id_fkey FOREIGN KEY (gallery_id) REFERENCES public.galleries(id) ON DELETE CASCADE; + + +-- +-- Name: gallery_image_notifications gallery_image_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.gallery_image_notifications + ADD CONSTRAINT gallery_image_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: image_comment_notifications image_comment_notifications_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_comment_notifications + ADD CONSTRAINT image_comment_notifications_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE; + + +-- +-- Name: image_comment_notifications image_comment_notifications_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_comment_notifications + ADD CONSTRAINT image_comment_notifications_image_id_fkey FOREIGN KEY (image_id) REFERENCES public.images(id) ON DELETE CASCADE; + + +-- +-- Name: image_comment_notifications image_comment_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_comment_notifications + ADD CONSTRAINT image_comment_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: image_merge_notifications image_merge_notifications_source_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_merge_notifications + ADD CONSTRAINT image_merge_notifications_source_id_fkey FOREIGN KEY (source_id) REFERENCES public.images(id) ON DELETE CASCADE; + + +-- +-- Name: image_merge_notifications image_merge_notifications_target_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_merge_notifications + ADD CONSTRAINT image_merge_notifications_target_id_fkey FOREIGN KEY (target_id) REFERENCES public.images(id) ON DELETE CASCADE; + + +-- +-- Name: image_merge_notifications image_merge_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.image_merge_notifications + ADD CONSTRAINT image_merge_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: image_sources image_sources_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5056,3 +5446,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20211107130226); INSERT INTO public."schema_migrations" (version) VALUES (20211219194836); INSERT INTO public."schema_migrations" (version) VALUES (20220321173359); INSERT INTO public."schema_migrations" (version) VALUES (20240723122759); +INSERT INTO public."schema_migrations" (version) VALUES (20240728191353); From f48a8fc165bce7180eb84d4ac7b9538ecae070f4 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 7 Jul 2024 18:09:20 -0400 Subject: [PATCH 045/115] Frontend changes --- lib/philomena/channels.ex | 16 +- lib/philomena/comments.ex | 17 +- lib/philomena/forums.ex | 1 - lib/philomena/galleries.ex | 34 +- lib/philomena/images.ex | 50 +-- lib/philomena/notifications.ex | 335 +++++++++--------- lib/philomena/notifications/category.ex | 173 ++++++--- .../channel_live_notification.ex | 25 ++ lib/philomena/notifications/creator.ex | 123 +++++++ .../notifications/forum_post_notification.ex | 27 ++ .../notifications/forum_topic_notification.ex | 25 ++ .../gallery_image_notification.ex | 25 ++ .../image_comment_notification.ex | 27 ++ .../notifications/image_merge_notification.ex | 26 ++ lib/philomena/notifications/notification.ex | 26 -- .../notifications/unread_notification.ex | 21 -- lib/philomena/posts.ex | 17 +- lib/philomena/subscriptions.ex | 40 +-- lib/philomena/topics.ex | 41 +-- lib/philomena/users/user.ex | 3 - .../controllers/channel/read_controller.ex | 2 +- .../controllers/channel_controller.ex | 2 +- .../controllers/forum/read_controller.ex | 22 -- .../controllers/gallery/read_controller.ex | 2 +- .../controllers/gallery_controller.ex | 2 +- .../controllers/image/read_controller.ex | 2 +- .../controllers/image_controller.ex | 2 +- .../notification/category_controller.ex | 8 +- .../controllers/notification_controller.ex | 6 +- .../controllers/topic/read_controller.ex | 2 +- .../controllers/topic_controller.ex | 5 +- .../plugs/notification_count_plug.ex | 2 +- lib/philomena_web/router.ex | 2 - .../notification/_channel.html.slime | 10 +- .../notification/_comment.html.slime | 22 ++ .../templates/notification/_forum.html.slime | 25 -- .../notification/_gallery.html.slime | 14 +- .../templates/notification/_image.html.slime | 23 +- .../notification/_notification.html.slime | 7 - .../templates/notification/_post.html.slime | 19 + .../templates/notification/_topic.html.slime | 20 +- .../notification/category/show.html.slime | 7 +- .../templates/notification/index.html.slime | 34 +- .../views/notification/category_view.ex | 3 +- lib/philomena_web/views/notification_view.ex | 20 +- 45 files changed, 773 insertions(+), 542 deletions(-) create mode 100644 lib/philomena/notifications/channel_live_notification.ex create mode 100644 lib/philomena/notifications/creator.ex create mode 100644 lib/philomena/notifications/forum_post_notification.ex create mode 100644 lib/philomena/notifications/forum_topic_notification.ex create mode 100644 lib/philomena/notifications/gallery_image_notification.ex create mode 100644 lib/philomena/notifications/image_comment_notification.ex create mode 100644 lib/philomena/notifications/image_merge_notification.ex delete mode 100644 lib/philomena/notifications/notification.ex delete mode 100644 lib/philomena/notifications/unread_notification.ex delete mode 100644 lib/philomena_web/controllers/forum/read_controller.ex create mode 100644 lib/philomena_web/templates/notification/_comment.html.slime delete mode 100644 lib/philomena_web/templates/notification/_forum.html.slime delete mode 100644 lib/philomena_web/templates/notification/_notification.html.slime create mode 100644 lib/philomena_web/templates/notification/_post.html.slime diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index 0f00cbe9..2ffd4ce4 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -8,10 +8,10 @@ defmodule Philomena.Channels do alias Philomena.Channels.AutomaticUpdater alias Philomena.Channels.Channel + alias Philomena.Notifications alias Philomena.Tags use Philomena.Subscriptions, - actor_types: ~w(Channel LivestreamChannel), id_name: :channel_id @doc """ @@ -139,4 +139,18 @@ defmodule Philomena.Channels do def change_channel(%Channel{} = channel) do Channel.changeset(channel, %{}) end + + @doc """ + Removes all channel notifications for a given channel and user. + + ## Examples + + iex> clear_channel_notification(channel, user) + :ok + + """ + def clear_channel_notification(%Channel{} = channel, user) do + Notifications.clear_channel_live_notification(channel, user) + :ok + end end diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index d3a819d9..281de6c0 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -79,22 +79,7 @@ defmodule Philomena.Comments do |> Repo.preload(:image) |> Map.fetch!(:image) - subscriptions = - image - |> Repo.preload(:subscriptions) - |> Map.fetch!(:subscriptions) - - Notifications.notify( - comment, - subscriptions, - %{ - actor_id: image.id, - actor_type: "Image", - actor_child_id: comment.id, - actor_child_type: "Comment", - action: "commented on" - } - ) + Notifications.create_image_comment_notification(image, comment) end @doc """ diff --git a/lib/philomena/forums.ex b/lib/philomena/forums.ex index ba8006bc..7cec4205 100644 --- a/lib/philomena/forums.ex +++ b/lib/philomena/forums.ex @@ -9,7 +9,6 @@ defmodule Philomena.Forums do alias Philomena.Forums.Forum use Philomena.Subscriptions, - actor_types: ~w(Forum), id_name: :forum_id @doc """ diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index b60b8b72..4c76df35 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -19,7 +19,6 @@ defmodule Philomena.Galleries do alias Philomena.Images use Philomena.Subscriptions, - actor_types: ~w(Gallery), id_name: :gallery_id @doc """ @@ -269,25 +268,10 @@ defmodule Philomena.Galleries do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Galleries", [gallery.id, image.id]]) end - def perform_notify([gallery_id, image_id]) do + def perform_notify([gallery_id, _image_id]) do gallery = get_gallery!(gallery_id) - subscriptions = - gallery - |> Repo.preload(:subscriptions) - |> Map.fetch!(:subscriptions) - - Notifications.notify( - gallery, - subscriptions, - %{ - actor_id: gallery.id, - actor_type: "Gallery", - actor_child_id: image_id, - actor_child_type: "Image", - action: "added images to" - } - ) + Notifications.create_gallery_image_notification(gallery) end def reorder_gallery(gallery, image_ids) do @@ -360,4 +344,18 @@ defmodule Philomena.Galleries do defp position_order(%{order_position_asc: true}), do: [asc: :position] defp position_order(_gallery), do: [desc: :position] + + @doc """ + Removes all gallery notifications for a given gallery and user. + + ## Examples + + iex> clear_gallery_notification(gallery, user) + :ok + + """ + def clear_gallery_notification(%Gallery{} = gallery, user) do + Notifications.clear_gallery_image_notification(gallery, user) + :ok + end end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 7d7b27ea..3aefa4c2 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -22,7 +22,8 @@ defmodule Philomena.Images do alias Philomena.IndexWorker alias Philomena.ImageFeatures.ImageFeature alias Philomena.SourceChanges.SourceChange - alias Philomena.Notifications.Notification + alias Philomena.Notifications.ImageCommentNotification + alias Philomena.Notifications.ImageMergeNotification alias Philomena.NotificationWorker alias Philomena.TagChanges.Limits alias Philomena.TagChanges.TagChange @@ -38,7 +39,6 @@ defmodule Philomena.Images do alias Philomena.Users.User use Philomena.Subscriptions, - actor_types: ~w(Image), id_name: :image_id @doc """ @@ -905,12 +905,17 @@ defmodule Philomena.Images do Repo.insert_all(Subscription, subscriptions, on_conflict: :nothing) - {count, nil} = - Notification - |> where(actor_type: "Image", actor_id: ^source.id) - |> Repo.delete_all() + {comment_notification_count, nil} = + ImageCommentNotification + |> where(image_id: ^source.id) + |> Repo.update_all(set: [image_id: target.id]) - {:ok, count} + {merge_notification_count, nil} = + ImageMergeNotification + |> where(image_id: ^source.id) + |> Repo.update_all(set: [image_id: target.id]) + + {:ok, {comment_notification_count, merge_notification_count}} end def migrate_sources(source, target) do @@ -930,23 +935,24 @@ defmodule Philomena.Images do end def perform_notify([source_id, target_id]) do + source = get_image!(source_id) target = get_image!(target_id) - subscriptions = - target - |> Repo.preload(:subscriptions) - |> Map.fetch!(:subscriptions) + Notifications.create_image_merge_notification(target, source) + end - Notifications.notify( - nil, - subscriptions, - %{ - actor_id: target.id, - actor_type: "Image", - actor_child_id: nil, - actor_child_type: nil, - action: "merged ##{source_id} into" - } - ) + @doc """ + Removes all image notifications for a given image and user. + + ## Examples + + iex> clear_image_notification(image, user) + :ok + + """ + def clear_image_notification(%Image{} = image, user) do + Notifications.clear_image_comment_notification(image, user) + Notifications.clear_image_merge_notification(image, user) + :ok end end diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index cbff31ee..ea0b6029 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -4,277 +4,262 @@ defmodule Philomena.Notifications do """ import Ecto.Query, warn: false - alias Philomena.Repo + + alias Philomena.Channels.Subscription, as: ChannelSubscription + alias Philomena.Forums.Subscription, as: ForumSubscription + alias Philomena.Galleries.Subscription, as: GallerySubscription + alias Philomena.Images.Subscription, as: ImageSubscription + alias Philomena.Topics.Subscription, as: TopicSubscription + + alias Philomena.Notifications.ChannelLiveNotification + alias Philomena.Notifications.ForumPostNotification + alias Philomena.Notifications.ForumTopicNotification + alias Philomena.Notifications.GalleryImageNotification + alias Philomena.Notifications.ImageCommentNotification + alias Philomena.Notifications.ImageMergeNotification alias Philomena.Notifications.Category - alias Philomena.Notifications.Notification - alias Philomena.Notifications.UnreadNotification - alias Philomena.Polymorphic + alias Philomena.Notifications.Creator @doc """ - Returns the list of unread notifications of the given type. - - The set of valid types is `t:Philomena.Notifications.Category.t/0`. + Return the count of all currently unread notifications for the user in all categories. ## Examples - iex> unread_notifications_for_user_and_type(user, :image_comment, ...) - [%Notification{}, ...] + iex> total_unread_notification_count(user) + 15 """ - def unread_notifications_for_user_and_type(user, type, pagination) do - notifications = - user - |> unread_query_for_type(type) - |> Repo.paginate(pagination) - - put_in(notifications.entries, load_associations(notifications.entries)) + def total_unread_notification_count(user) do + Category.total_unread_notification_count(user) end @doc """ - Gather up and return the top N notifications for the user, for each type of + Gather up and return the top N notifications for the user, for each category of unread notification currently existing. ## Examples - iex> unread_notifications_for_user(user) - [ - forum_topic: [%Notification{...}, ...], - forum_post: [%Notification{...}, ...], - image_comment: [%Notification{...}, ...] - ] + iex> unread_notifications_for_user(user, page_size: 10) + %{ + channel_live: [], + forum_post: [%ForumPostNotification{...}, ...], + forum_topic: [%ForumTopicNotification{...}, ...], + gallery_image: [], + image_comment: [%ImageCommentNotification{...}, ...], + image_merge: [] + } """ - def unread_notifications_for_user(user, n) do - Category.types() - |> Enum.map(fn type -> - q = - user - |> unread_query_for_type(type) - |> limit(^n) - - # Use a subquery to ensure the order by is applied to the - # subquery results only, and not the main query results - from(n in subquery(q)) - end) - |> union_all_queries() - |> Repo.all() - |> load_associations() - |> Enum.group_by(&Category.notification_type/1) - |> Enum.sort_by(fn {k, _v} -> k end) + def unread_notifications_for_user(user, pagination) do + Category.unread_notifications_for_user(user, pagination) end - defp unread_query_for_type(user, type) do - from n in Category.query_for_type(type), - join: un in UnreadNotification, - on: un.notification_id == n.id, - where: un.user_id == ^user.id, - order_by: [desc: :updated_at] + @doc """ + Returns paginated unread notifications for the user, given the category. + + ## Examples + + iex> unread_notifications_for_user_and_category(user, :image_comment) + [%ImageCommentNotification{...}] + + """ + def unread_notifications_for_user_and_category(user, category, pagination) do + Category.unread_notifications_for_user_and_category(user, category, pagination) end - defp union_all_queries([query | rest]) do - Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) + @doc """ + Creates a channel live notification, returning the number of affected users. + + ## Examples + + iex> create_channel_live_notification(channel) + {:ok, 2} + + """ + def create_channel_live_notification(channel) do + Creator.create_single(ChannelSubscription, ChannelLiveNotification, :channel_id, channel) end - defp load_associations(notifications) do - Polymorphic.load_polymorphic( - notifications, - actor: [actor_id: :actor_type], - actor_child: [actor_child_id: :actor_child_type] + @doc """ + Creates a forum post notification, returning the number of affected users. + + ## Examples + + iex> create_forum_post_notification(topic, post) + {:ok, 2} + + """ + def create_forum_post_notification(topic, post) do + Creator.create_double( + TopicSubscription, + ForumPostNotification, + :topic_id, + topic, + :post_id, + post ) end @doc """ - Gets a single notification. - - Raises `Ecto.NoResultsError` if the Notification does not exist. + Creates a forum topic notification, returning the number of affected users. ## Examples - iex> get_notification!(123) - %Notification{} - - iex> get_notification!(456) - ** (Ecto.NoResultsError) + iex> create_forum_topic_notification(topic) + {:ok, 2} """ - def get_notification!(id), do: Repo.get!(Notification, id) - - @doc """ - Creates a notification. - - ## Examples - - iex> create_notification(%{field: value}) - {:ok, %Notification{}} - - iex> create_notification(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_notification(attrs \\ %{}) do - %Notification{} - |> Notification.changeset(attrs) - |> Repo.insert() + def create_forum_topic_notification(topic) do + Creator.create_single(ForumSubscription, ForumTopicNotification, :topic_id, topic) end @doc """ - Updates a notification. + Creates a gallery image notification, returning the number of affected users. ## Examples - iex> update_notification(notification, %{field: new_value}) - {:ok, %Notification{}} - - iex> update_notification(notification, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> create_gallery_image_notification(gallery) + {:ok, 2} """ - def update_notification(%Notification{} = notification, attrs) do - notification - |> Notification.changeset(attrs) - |> Repo.insert_or_update() + def create_gallery_image_notification(gallery) do + Creator.create_single(GallerySubscription, GalleryImageNotification, :gallery_id, gallery) end @doc """ - Deletes a Notification. + Creates an image comment notification, returning the number of affected users. ## Examples - iex> delete_notification(notification) - {:ok, %Notification{}} - - iex> delete_notification(notification) - {:error, %Ecto.Changeset{}} + iex> create_image_comment_notification(image, comment) + {:ok, 2} """ - def delete_notification(%Notification{} = notification) do - Repo.delete(notification) + def create_image_comment_notification(image, comment) do + Creator.create_double( + ImageSubscription, + ImageCommentNotification, + :image_id, + image, + :comment_id, + comment + ) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking notification changes. + Creates an image merge notification, returning the number of affected users. ## Examples - iex> change_notification(notification) - %Ecto.Changeset{source: %Notification{}} + iex> create_image_merge_notification(target, source) + {:ok, 2} """ - def change_notification(%Notification{} = notification) do - Notification.changeset(notification, %{}) - end - - def count_unread_notifications(user) do - UnreadNotification - |> where(user_id: ^user.id) - |> Repo.aggregate(:count, :notification_id) + def create_image_merge_notification(target, source) do + Creator.create_double( + ImageSubscription, + ImageMergeNotification, + :target_id, + target, + :source_id, + source + ) end @doc """ - Creates a unread_notification. + Removes the channel live notification for a given channel and user, returning + the number of affected users. ## Examples - iex> create_unread_notification(%{field: value}) - {:ok, %UnreadNotification{}} - - iex> create_unread_notification(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> clear_channel_live_notification(channel, user) + {:ok, 2} """ - def create_unread_notification(attrs \\ %{}) do - %UnreadNotification{} - |> UnreadNotification.changeset(attrs) - |> Repo.insert() + def clear_channel_live_notification(channel, user) do + ChannelLiveNotification + |> where(channel_id: ^channel.id) + |> Creator.clear(user) end @doc """ - Updates a unread_notification. + Removes the forum post notification for a given topic and user, returning + the number of affected notifications. ## Examples - iex> update_unread_notification(unread_notification, %{field: new_value}) - {:ok, %UnreadNotification{}} - - iex> update_unread_notification(unread_notification, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> clear_forum_post_notification(topic, user) + {:ok, 2} """ - def update_unread_notification(%UnreadNotification{} = unread_notification, attrs) do - unread_notification - |> UnreadNotification.changeset(attrs) - |> Repo.update() + def clear_forum_post_notification(topic, user) do + ForumPostNotification + |> where(topic_id: ^topic.id) + |> Creator.clear(user) end @doc """ - Deletes a UnreadNotification. + Removes the forum topic notification for a given topic and user, returning + the number of affected notifications. ## Examples - iex> delete_unread_notification(unread_notification) - {:ok, %UnreadNotification{}} - - iex> delete_unread_notification(unread_notification) - {:error, %Ecto.Changeset{}} + iex> clear_forum_topic_notification(topic, user) + {:ok, 2} """ - def delete_unread_notification(actor_type, actor_id, user) do - notification = - Notification - |> where(actor_type: ^actor_type, actor_id: ^actor_id) - |> Repo.one() - - if notification do - UnreadNotification - |> where(notification_id: ^notification.id, user_id: ^user.id) - |> Repo.delete_all() - end + def clear_forum_topic_notification(topic, user) do + ForumTopicNotification + |> where(topic_id: ^topic.id) + |> Creator.clear(user) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking unread_notification changes. + Removes the gallery image notification for a given gallery and user, returning + the number of affected notifications. ## Examples - iex> change_unread_notification(unread_notification) - %Ecto.Changeset{source: %UnreadNotification{}} + iex> clear_gallery_image_notification(topic, user) + {:ok, 2} """ - def change_unread_notification(%UnreadNotification{} = unread_notification) do - UnreadNotification.changeset(unread_notification, %{}) + def clear_gallery_image_notification(gallery, user) do + GalleryImageNotification + |> where(gallery_id: ^gallery.id) + |> Creator.clear(user) end - def notify(_actor_child, [], _params), do: nil + @doc """ + Removes the image comment notification for a given image and user, returning + the number of affected notifications. - def notify(actor_child, subscriptions, params) do - # Don't push to the user that created the notification - subscriptions = - case actor_child do - %{user_id: id} -> - subscriptions - |> Enum.reject(&(&1.user_id == id)) + ## Examples - _ -> - subscriptions - end + iex> clear_gallery_image_notification(topic, user) + {:ok, 2} - Repo.transaction(fn -> - notification = - Notification - |> Repo.get_by(actor_id: params.actor_id, actor_type: params.actor_type) + """ + def clear_image_comment_notification(image, user) do + ImageCommentNotification + |> where(image_id: ^image.id) + |> Creator.clear(user) + end - {:ok, notification} = - (notification || %Notification{}) - |> update_notification(params) + @doc """ + Removes the image merge notification for a given image and user, returning + the number of affected notifications. - # Insert the notification to any watchers who do not have it - unreads = - subscriptions - |> Enum.map(&%{user_id: &1.user_id, notification_id: notification.id}) + ## Examples - UnreadNotification - |> Repo.insert_all(unreads, on_conflict: :nothing) - end) + iex> clear_image_merge_notification(topic, user) + {:ok, 2} + + """ + def clear_image_merge_notification(image, user) do + ImageMergeNotification + |> where(target_id: ^image.id) + |> Creator.clear(user) end end diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex index 775b888d..249dd838 100644 --- a/lib/philomena/notifications/category.ex +++ b/lib/philomena/notifications/category.ex @@ -1,10 +1,17 @@ defmodule Philomena.Notifications.Category do @moduledoc """ - Notification category determination. + Notification category querying. """ import Ecto.Query, warn: false - alias Philomena.Notifications.Notification + alias Philomena.Repo + + alias Philomena.Notifications.ChannelLiveNotification + alias Philomena.Notifications.ForumPostNotification + alias Philomena.Notifications.ForumTopicNotification + alias Philomena.Notifications.GalleryImageNotification + alias Philomena.Notifications.ImageCommentNotification + alias Philomena.Notifications.ImageMergeNotification @type t :: :channel_live @@ -15,79 +22,145 @@ defmodule Philomena.Notifications.Category do | :image_merge @doc """ - Return a list of all supported types. + Return a list of all supported categories. """ - def types do + def categories do [ :channel_live, + :forum_post, :forum_topic, :gallery_image, :image_comment, - :image_merge, - :forum_post + :image_merge ] end @doc """ - Determine the type of a `m:Philomena.Notifications.Notification`. + Return the count of all currently unread notifications for the user in all categories. + + ## Examples + + iex> total_unread_notification_count(user) + 15 + """ - def notification_type(n) do - case {n.actor_type, n.actor_child_type} do - {"Channel", _} -> - :channel_live + def total_unread_notification_count(user) do + categories() + |> Enum.map(fn category -> + category + |> query_for_category_and_user(user) + |> exclude(:preload) + |> select([_], %{one: 1}) + end) + |> union_all_queries() + |> Repo.aggregate(:count) + end - {"Gallery", _} -> - :gallery_image + defp union_all_queries([query | rest]) do + Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) + end - {"Image", "Comment"} -> - :image_comment + @doc """ + Gather up and return the top N notifications for the user, for each category of + unread notification currently existing. - {"Image", _} -> - :image_merge + ## Examples - {"Topic", "Post"} -> - if n.action == "posted a new reply in" do - :forum_post - else - :forum_topic - end + iex> unread_notifications_for_user(user, page_size: 10) + %{ + channel_live: [], + forum_post: [%ForumPostNotification{...}, ...], + forum_topic: [%ForumTopicNotification{...}, ...], + gallery_image: [], + image_comment: [%ImageCommentNotification{...}, ...], + image_merge: [] + } + + """ + def unread_notifications_for_user(user, pagination) do + Map.new(categories(), fn category -> + results = + category + |> query_for_category_and_user(user) + |> order_by(desc: :updated_at) + |> Repo.paginate(pagination) + + {category, results} + end) + end + + @doc """ + Returns paginated unread notifications for the user, given the category. + + ## Examples + + iex> unread_notifications_for_user_and_category(user, :image_comment) + [%ImageCommentNotification{...}] + + """ + def unread_notifications_for_user_and_category(user, category, pagination) do + category + |> query_for_category_and_user(user) + |> order_by(desc: :updated_at) + |> Repo.paginate(pagination) + end + + @doc """ + Determine the category of a notification. + + ## Examples + + iex> notification_category(%ImageCommentNotification{}) + :image_comment + + """ + def notification_category(n) do + case n.__struct__ do + ChannelLiveNotification -> :channel_live + GalleryImageNotification -> :gallery_image + ImageCommentNotification -> :image_comment + ImageMergeNotification -> :image_merge + ForumPostNotification -> :forum_post + ForumTopicNotification -> :forum_topic end end @doc """ - Returns an `m:Ecto.Query` that finds notifications for the given type. + Returns an `m:Ecto.Query` that finds unread notifications for the given category, + for the given user, with preloads applied. + + ## Examples + + iex> query_for_category_and_user(:channel_live, user) + #Ecto.Query + """ - def query_for_type(type) do - base = from(n in Notification) + def query_for_category_and_user(category, user) do + query = + case category do + :channel_live -> + from(n in ChannelLiveNotification, preload: :channel) - case type do - :channel_live -> - where(base, [n], n.actor_type == "Channel") + :gallery_image -> + from(n in GalleryImageNotification, preload: [gallery: :creator]) - :gallery_image -> - where(base, [n], n.actor_type == "Gallery") + :image_comment -> + from(n in ImageCommentNotification, + preload: [image: [:sources, tags: :aliases], comment: :user] + ) - :image_comment -> - where(base, [n], n.actor_type == "Image" and n.actor_child_type == "Comment") + :image_merge -> + from(n in ImageMergeNotification, + preload: [:source, target: [:sources, tags: :aliases]] + ) - :image_merge -> - where(base, [n], n.actor_type == "Image" and is_nil(n.actor_child_type)) + :forum_topic -> + from(n in ForumTopicNotification, preload: [topic: [:forum, :user]]) - :forum_topic -> - where( - base, - [n], - n.actor_type == "Topic" and n.actor_child_type == "Post" and - n.action != "posted a new reply in" - ) + :forum_post -> + from(n in ForumPostNotification, preload: [topic: :forum, post: :user]) + end - :forum_post -> - where( - base, - [n], - n.actor_type == "Topic" and n.actor_child_type == "Post" and - n.action == "posted a new reply in" - ) - end + where(query, user_id: ^user.id) end end diff --git a/lib/philomena/notifications/channel_live_notification.ex b/lib/philomena/notifications/channel_live_notification.ex new file mode 100644 index 00000000..b60fb8e6 --- /dev/null +++ b/lib/philomena/notifications/channel_live_notification.ex @@ -0,0 +1,25 @@ +defmodule Philomena.Notifications.ChannelLiveNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Channels.Channel + + @primary_key false + + schema "channel_live_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :channel, Channel, primary_key: true + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(channel_live_notification, attrs) do + channel_live_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex new file mode 100644 index 00000000..5a04b724 --- /dev/null +++ b/lib/philomena/notifications/creator.ex @@ -0,0 +1,123 @@ +defmodule Philomena.Notifications.Creator do + @moduledoc """ + Internal notifications creation logic. + + Supports two formats for notification creation: + - Key-only (`create_single/4`): The object's id is the only other component inserted. + - Non-key (`create_double/6`): The object's id plus another object's id are inserted. + + See the respective documentation for each function for more details. + """ + + import Ecto.Query, warn: false + alias Philomena.Repo + + @doc """ + Propagate notifications for a notification table type containing a single reference column. + + The single reference column (`name`, `object`) is also part of the unique key for the table, + and is used to select which object to act on. + + Returns `{:ok, count}`, where `count` is the number of affected rows. + + ## Example + + iex> create_single(GallerySubscription, GalleryImageNotification, :gallery_id, gallery) + {:ok, 2} + + """ + def create_single(subscription, notification, name, object) do + subscription + |> create_notification_query(name, object) + |> create_notification(notification, name) + end + + @doc """ + Propagate notifications for a notification table type containing two reference columns. + + The first reference column (`name1`, `object1`) is also part of the unique key for the table, + and is used to select which object to act on. + + Returns `{:ok, count}`, where `count` is the number of affected rows. + + ## Example + + iex> create_double( + ...> ImageSubscription, + ...> ImageCommentNotification, + ...> :image_id, + ...> image, + ...> :comment_id, + ...> comment + ...> ) + {:ok, 2} + + """ + def create_double(subscription, notification, name1, object1, name2, object2) do + subscription + |> create_notification_query(name1, object1, name2, object2) + |> create_notification(notification, name1) + end + + @doc """ + Clear all unread notifications using the given query. + + Returns `{:ok, count}`, where `count` is the number of affected rows. + """ + def clear(query, user) do + if user do + {count, nil} = + query + |> where(user_id: ^user.id) + |> Repo.delete_all() + + {:ok, count} + else + {:ok, 0} + end + end + + # TODO: the following cannot be accomplished with a single query expression + # due to this Ecto bug: https://github.com/elixir-ecto/ecto/issues/4430 + + defp create_notification_query(subscription, name, object) do + now = DateTime.utc_now(:second) + + from s in subscription, + where: field(s, ^name) == ^object.id, + select: %{ + ^name => type(^object.id, :integer), + user_id: s.user_id, + created_at: ^now, + updated_at: ^now, + read: false + } + end + + defp create_notification_query(subscription, name1, object1, name2, object2) do + now = DateTime.utc_now(:second) + + from s in subscription, + where: field(s, ^name1) == ^object1.id, + select: %{ + ^name1 => type(^object1.id, :integer), + ^name2 => type(^object2.id, :integer), + user_id: s.user_id, + created_at: ^now, + updated_at: ^now, + read: false + } + end + + defp create_notification(query, notification, name) do + {count, nil} = + Repo.insert_all( + notification, + query, + on_conflict: {:replace_all_except, [:created_at]}, + conflict_target: [name, :user_id] + ) + + {:ok, count} + end +end diff --git a/lib/philomena/notifications/forum_post_notification.ex b/lib/philomena/notifications/forum_post_notification.ex new file mode 100644 index 00000000..0d2ad20a --- /dev/null +++ b/lib/philomena/notifications/forum_post_notification.ex @@ -0,0 +1,27 @@ +defmodule Philomena.Notifications.ForumPostNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Topics.Topic + alias Philomena.Posts.Post + + @primary_key false + + schema "forum_post_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :topic, Topic, primary_key: true + belongs_to :post, Post + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(forum_post_notification, attrs) do + forum_post_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/forum_topic_notification.ex b/lib/philomena/notifications/forum_topic_notification.ex new file mode 100644 index 00000000..862f42ae --- /dev/null +++ b/lib/philomena/notifications/forum_topic_notification.ex @@ -0,0 +1,25 @@ +defmodule Philomena.Notifications.ForumTopicNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Topics.Topic + + @primary_key false + + schema "forum_topic_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :topic, Topic, primary_key: true + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(forum_topic_notification, attrs) do + forum_topic_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/gallery_image_notification.ex b/lib/philomena/notifications/gallery_image_notification.ex new file mode 100644 index 00000000..1d00d7c9 --- /dev/null +++ b/lib/philomena/notifications/gallery_image_notification.ex @@ -0,0 +1,25 @@ +defmodule Philomena.Notifications.GalleryImageNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Galleries.Gallery + + @primary_key false + + schema "gallery_image_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :gallery, Gallery, primary_key: true + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(gallery_image_notification, attrs) do + gallery_image_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/image_comment_notification.ex b/lib/philomena/notifications/image_comment_notification.ex new file mode 100644 index 00000000..08a2ddff --- /dev/null +++ b/lib/philomena/notifications/image_comment_notification.ex @@ -0,0 +1,27 @@ +defmodule Philomena.Notifications.ImageCommentNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Images.Image + alias Philomena.Comments.Comment + + @primary_key false + + schema "image_comment_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :image, Image, primary_key: true + belongs_to :comment, Comment + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(image_comment_notification, attrs) do + image_comment_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/image_merge_notification.ex b/lib/philomena/notifications/image_merge_notification.ex new file mode 100644 index 00000000..5546707e --- /dev/null +++ b/lib/philomena/notifications/image_merge_notification.ex @@ -0,0 +1,26 @@ +defmodule Philomena.Notifications.ImageMergeNotification do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Users.User + alias Philomena.Images.Image + + @primary_key false + + schema "image_merge_notifications" do + belongs_to :user, User, primary_key: true + belongs_to :target, Image, primary_key: true + belongs_to :source, Image + + field :read, :boolean, default: false + + timestamps(inserted_at: :created_at, type: :utc_datetime) + end + + @doc false + def changeset(image_merge_notification, attrs) do + image_merge_notification + |> cast(attrs, []) + |> validate_required([]) + end +end diff --git a/lib/philomena/notifications/notification.ex b/lib/philomena/notifications/notification.ex deleted file mode 100644 index 72951bbe..00000000 --- a/lib/philomena/notifications/notification.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Philomena.Notifications.Notification do - use Ecto.Schema - import Ecto.Changeset - - schema "notifications" do - field :action, :string - - # fixme: rails polymorphic relation - field :actor_id, :integer - field :actor_type, :string - field :actor_child_id, :integer - field :actor_child_type, :string - - field :actor, :any, virtual: true - field :actor_child, :any, virtual: true - - timestamps(inserted_at: :created_at, type: :utc_datetime) - end - - @doc false - def changeset(notification, attrs) do - notification - |> cast(attrs, [:actor_id, :actor_type, :actor_child_id, :actor_child_type, :action]) - |> validate_required([:actor_id, :actor_type, :action]) - end -end diff --git a/lib/philomena/notifications/unread_notification.ex b/lib/philomena/notifications/unread_notification.ex deleted file mode 100644 index 1d111141..00000000 --- a/lib/philomena/notifications/unread_notification.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Philomena.Notifications.UnreadNotification do - use Ecto.Schema - import Ecto.Changeset - - alias Philomena.Users.User - alias Philomena.Notifications.Notification - - @primary_key false - - schema "unread_notifications" do - belongs_to :user, User, primary_key: true - belongs_to :notification, Notification, primary_key: true - end - - @doc false - def changeset(unread_notification, attrs) do - unread_notification - |> cast(attrs, []) - |> validate_required([]) - end -end diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 723ea6b6..2de6cfbb 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -128,22 +128,7 @@ defmodule Philomena.Posts do |> Repo.preload(:topic) |> Map.fetch!(:topic) - subscriptions = - topic - |> Repo.preload(:subscriptions) - |> Map.fetch!(:subscriptions) - - Notifications.notify( - post, - subscriptions, - %{ - actor_id: topic.id, - actor_type: "Topic", - actor_child_id: post.id, - actor_child_type: "Post", - action: "posted a new reply in" - } - ) + Notifications.create_forum_post_notification(topic, post) end @doc """ diff --git a/lib/philomena/subscriptions.ex b/lib/philomena/subscriptions.ex index 8c0d53f0..8e67183f 100644 --- a/lib/philomena/subscriptions.ex +++ b/lib/philomena/subscriptions.ex @@ -2,35 +2,26 @@ defmodule Philomena.Subscriptions do @moduledoc """ Common subscription logic. - `use Philomena.Subscriptions` requires the following properties: - - - `:actor_types` - This is the "actor_type" in the notifications table. - For `Philomena.Images`, this would be `["Image"]`. + `use Philomena.Subscriptions` requires the following option: - `:id_name` This is the name of the object field in the subscription table. - For `Philomena.Images`, this would be `:image_id`. + For `m:Philomena.Images`, this would be `:image_id`. The following functions and documentation are produced in the calling module: - `subscribed?/2` - `subscriptions/2` - `create_subscription/2` - `delete_subscription/2` - - `clear_notification/2` - `maybe_subscribe_on/4` """ import Ecto.Query, warn: false alias Ecto.Multi - alias Philomena.Notifications alias Philomena.Repo defmacro __using__(opts) do - # For Philomena.Images, this yields ["Image"] - actor_types = Keyword.fetch!(opts, :actor_types) - # For Philomena.Images, this yields :image_id field_name = Keyword.fetch!(opts, :id_name) @@ -109,8 +100,6 @@ defmodule Philomena.Subscriptions do """ def delete_subscription(object, user) do - clear_notification(object, user) - Philomena.Subscriptions.delete_subscription( unquote(subscription_module), unquote(field_name), @@ -119,23 +108,6 @@ defmodule Philomena.Subscriptions do ) end - @doc """ - Deletes any active notifications for a subscription. - - ## Examples - - iex> clear_notification(object, user) - :ok - - """ - def clear_notification(object, user) do - for type <- unquote(actor_types) do - Philomena.Subscriptions.clear_notification(type, object, user) - end - - :ok - end - @doc """ Creates a subscription inside the `m:Ecto.Multi` flow if `user` is not nil and `field` in `user` is `true`. @@ -199,14 +171,6 @@ defmodule Philomena.Subscriptions do |> Repo.delete() end - @doc false - def clear_notification(type, object, user) do - case user do - nil -> nil - _ -> Notifications.delete_unread_notification(type, object.id, user) - end - end - @doc false def maybe_subscribe_on(multi, module, change_name, user, field) when field in [:watch_on_reply, :watch_on_upload, :watch_on_new_topic] do diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 1a96f757..3ff4aba5 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -14,7 +14,6 @@ defmodule Philomena.Topics do alias Philomena.NotificationWorker use Philomena.Subscriptions, - actor_types: ~w(Topic), id_name: :topic_id @doc """ @@ -91,31 +90,10 @@ defmodule Philomena.Topics do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Topics", [topic.id, post.id]]) end - def perform_notify([topic_id, post_id]) do + def perform_notify([topic_id, _post_id]) do topic = get_topic!(topic_id) - post = Posts.get_post!(post_id) - forum = - topic - |> Repo.preload(:forum) - |> Map.fetch!(:forum) - - subscriptions = - forum - |> Repo.preload(:subscriptions) - |> Map.fetch!(:subscriptions) - - Notifications.notify( - post, - subscriptions, - %{ - actor_id: topic.id, - actor_type: "Topic", - actor_child_id: post.id, - actor_child_type: "Post", - action: "posted a new topic in #{forum.name}" - } - ) + Notifications.create_forum_topic_notification(topic) end @doc """ @@ -242,4 +220,19 @@ defmodule Philomena.Topics do |> Topic.title_changeset(attrs) |> Repo.update() end + + @doc """ + Removes all topic notifications for a given topic and user. + + ## Examples + + iex> clear_topic_notification(topic, user) + :ok + + """ + def clear_topic_notification(%Topic{} = topic, user) do + Notifications.clear_forum_post_notification(topic, user) + Notifications.clear_forum_topic_notification(topic, user) + :ok + end end diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index c005d92e..6b300a7c 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -12,7 +12,6 @@ defmodule Philomena.Users.User do alias Philomena.Filters.Filter alias Philomena.ArtistLinks.ArtistLink alias Philomena.Badges - alias Philomena.Notifications.UnreadNotification alias Philomena.Galleries.Gallery alias Philomena.Users.User alias Philomena.Commissions.Commission @@ -30,8 +29,6 @@ defmodule Philomena.Users.User do has_many :public_links, ArtistLink, where: [public: true, aasm_state: "verified"] has_many :galleries, Gallery, foreign_key: :creator_id has_many :awards, Badges.Award - has_many :unread_notifications, UnreadNotification - has_many :notifications, through: [:unread_notifications, :notification] has_many :linked_tags, through: [:verified_links, :tag] has_many :user_ips, UserIp has_many :user_fingerprints, UserFingerprint diff --git a/lib/philomena_web/controllers/channel/read_controller.ex b/lib/philomena_web/controllers/channel/read_controller.ex index 415c6b57..91787262 100644 --- a/lib/philomena_web/controllers/channel/read_controller.ex +++ b/lib/philomena_web/controllers/channel/read_controller.ex @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Channel.ReadController do channel = conn.assigns.channel user = conn.assigns.current_user - Channels.clear_notification(channel, user) + Channels.clear_channel_notification(channel, user) send_resp(conn, :ok, "") end diff --git a/lib/philomena_web/controllers/channel_controller.ex b/lib/philomena_web/controllers/channel_controller.ex index 6d88d257..a548dda9 100644 --- a/lib/philomena_web/controllers/channel_controller.ex +++ b/lib/philomena_web/controllers/channel_controller.ex @@ -37,7 +37,7 @@ defmodule PhilomenaWeb.ChannelController do channel = conn.assigns.channel user = conn.assigns.current_user - if user, do: Channels.clear_notification(channel, user) + Channels.clear_channel_notification(channel, user) redirect(conn, external: channel_url(channel)) end diff --git a/lib/philomena_web/controllers/forum/read_controller.ex b/lib/philomena_web/controllers/forum/read_controller.ex deleted file mode 100644 index cca7ee69..00000000 --- a/lib/philomena_web/controllers/forum/read_controller.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule PhilomenaWeb.Forum.ReadController do - import Plug.Conn - use PhilomenaWeb, :controller - - alias Philomena.Forums.Forum - alias Philomena.Forums - - plug :load_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - def create(conn, _params) do - forum = conn.assigns.forum - user = conn.assigns.current_user - - Forums.clear_notification(forum, user) - - send_resp(conn, :ok, "") - end -end diff --git a/lib/philomena_web/controllers/gallery/read_controller.ex b/lib/philomena_web/controllers/gallery/read_controller.ex index eee4e3d0..ffe1eb55 100644 --- a/lib/philomena_web/controllers/gallery/read_controller.ex +++ b/lib/philomena_web/controllers/gallery/read_controller.ex @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Gallery.ReadController do gallery = conn.assigns.gallery user = conn.assigns.current_user - Galleries.clear_notification(gallery, user) + Galleries.clear_gallery_notification(gallery, user) send_resp(conn, :ok, "") end diff --git a/lib/philomena_web/controllers/gallery_controller.ex b/lib/philomena_web/controllers/gallery_controller.ex index 64a020e0..0f1f5a71 100644 --- a/lib/philomena_web/controllers/gallery_controller.ex +++ b/lib/philomena_web/controllers/gallery_controller.ex @@ -80,7 +80,7 @@ defmodule PhilomenaWeb.GalleryController do gallery_json = Jason.encode!(Enum.map(gallery_images, &elem(&1, 0).id)) - Galleries.clear_notification(gallery, user) + Galleries.clear_gallery_notification(gallery, user) conn |> NotificationCountPlug.call([]) diff --git a/lib/philomena_web/controllers/image/read_controller.ex b/lib/philomena_web/controllers/image/read_controller.ex index 965b7fdc..c1715a66 100644 --- a/lib/philomena_web/controllers/image/read_controller.ex +++ b/lib/philomena_web/controllers/image/read_controller.ex @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Image.ReadController do image = conn.assigns.image user = conn.assigns.current_user - Images.clear_notification(image, user) + Images.clear_image_notification(image, user) send_resp(conn, :ok, "") end diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex index 9cb0914a..990fcfd3 100644 --- a/lib/philomena_web/controllers/image_controller.ex +++ b/lib/philomena_web/controllers/image_controller.ex @@ -56,7 +56,7 @@ defmodule PhilomenaWeb.ImageController do image = conn.assigns.image user = conn.assigns.current_user - Images.clear_notification(image, user) + Images.clear_image_notification(image, user) # Update the notification ticker in the header conn = NotificationCountPlug.call(conn) diff --git a/lib/philomena_web/controllers/notification/category_controller.ex b/lib/philomena_web/controllers/notification/category_controller.ex index 76142581..c050c4e9 100644 --- a/lib/philomena_web/controllers/notification/category_controller.ex +++ b/lib/philomena_web/controllers/notification/category_controller.ex @@ -4,19 +4,19 @@ defmodule PhilomenaWeb.Notification.CategoryController do alias Philomena.Notifications def show(conn, params) do - type = category(params) + category_param = category(params) notifications = - Notifications.unread_notifications_for_user_and_type( + Notifications.unread_notifications_for_user_and_category( conn.assigns.current_user, - type, + category_param, conn.assigns.scrivener ) render(conn, "show.html", title: "Notification Area", notifications: notifications, - type: type + category: category_param ) end diff --git a/lib/philomena_web/controllers/notification_controller.ex b/lib/philomena_web/controllers/notification_controller.ex index a21f345f..158fc5f3 100644 --- a/lib/philomena_web/controllers/notification_controller.ex +++ b/lib/philomena_web/controllers/notification_controller.ex @@ -4,7 +4,11 @@ defmodule PhilomenaWeb.NotificationController do alias Philomena.Notifications def index(conn, _params) do - notifications = Notifications.unread_notifications_for_user(conn.assigns.current_user, 15) + notifications = + Notifications.unread_notifications_for_user( + conn.assigns.current_user, + page_size: 10 + ) render(conn, "index.html", title: "Notification Area", notifications: notifications) end diff --git a/lib/philomena_web/controllers/topic/read_controller.ex b/lib/philomena_web/controllers/topic/read_controller.ex index 1c5c45b4..0ac80560 100644 --- a/lib/philomena_web/controllers/topic/read_controller.ex +++ b/lib/philomena_web/controllers/topic/read_controller.ex @@ -16,7 +16,7 @@ defmodule PhilomenaWeb.Topic.ReadController do def create(conn, _params) do user = conn.assigns.current_user - Topics.clear_notification(conn.assigns.topic, user) + Topics.clear_topic_notification(conn.assigns.topic, user) send_resp(conn, :ok, "") end diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex index e88670a0..f68fbcda 100644 --- a/lib/philomena_web/controllers/topic_controller.ex +++ b/lib/philomena_web/controllers/topic_controller.ex @@ -3,7 +3,7 @@ defmodule PhilomenaWeb.TopicController do alias PhilomenaWeb.NotificationCountPlug alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption} - alias Philomena.{Forums, Topics, Polls, Posts} + alias Philomena.{Topics, Polls, Posts} alias Philomena.PollVotes alias PhilomenaWeb.MarkdownRenderer alias Philomena.Repo @@ -34,8 +34,7 @@ defmodule PhilomenaWeb.TopicController do user = conn.assigns.current_user - Topics.clear_notification(topic, user) - Forums.clear_notification(forum, user) + Topics.clear_topic_notification(topic, user) # Update the notification ticker in the header conn = NotificationCountPlug.call(conn) diff --git a/lib/philomena_web/plugs/notification_count_plug.ex b/lib/philomena_web/plugs/notification_count_plug.ex index d8afbef9..8f4f7913 100644 --- a/lib/philomena_web/plugs/notification_count_plug.ex +++ b/lib/philomena_web/plugs/notification_count_plug.ex @@ -32,7 +32,7 @@ defmodule PhilomenaWeb.NotificationCountPlug do defp maybe_assign_notifications(conn, nil), do: conn defp maybe_assign_notifications(conn, user) do - notifications = Notifications.count_unread_notifications(user) + notifications = Notifications.total_unread_notification_count(user) Conn.assign(conn, :notification_count, notifications) end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index be9f48e0..2ed82dc1 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -263,8 +263,6 @@ defmodule PhilomenaWeb.Router do resources "/subscription", Forum.SubscriptionController, only: [:create, :delete], singleton: true - - resources "/read", Forum.ReadController, only: [:create], singleton: true end resources "/profiles", ProfileController, only: [] do diff --git a/lib/philomena_web/templates/notification/_channel.html.slime b/lib/philomena_web/templates/notification/_channel.html.slime index 1fc57157..0c4c5b73 100644 --- a/lib/philomena_web/templates/notification/_channel.html.slime +++ b/lib/philomena_web/templates/notification/_channel.html.slime @@ -1,14 +1,14 @@ .flex.flex--centered.flex__grow div strong> - = link @notification.actor.title, to: ~p"/channels/#{@notification.actor}" - =<> @notification.action + = link @notification.channel.title, to: ~p"/channels/#{@notification.channel}" + ' went live => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" + a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.channel}/read" data-method="post" data-remote="true" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/channels/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-bell-slash \ No newline at end of file + a.button title="Unsubscribe" href=~p"/channels/#{@notification.channel}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_comment.html.slime b/lib/philomena_web/templates/notification/_comment.html.slime new file mode 100644 index 00000000..4e9efeb6 --- /dev/null +++ b/lib/philomena_web/templates/notification/_comment.html.slime @@ -0,0 +1,22 @@ +- comment = @notification.comment +- image = @notification.image + +.flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right + = render PhilomenaWeb.ImageView, "_image_container.html", image: image, size: :thumb_tiny, conn: @conn + +.flex.flex--centered.flex__grow + div + => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: comment, conn: @conn + ' commented on + + strong> + = link "##{image.id}", to: ~p"/images/#{image}" <> "#comments" + + => pretty_time @notification.updated_at + +.flex.flex--centered.flex--no-wrap + a.button.button--separate-right title="Delete" href=~p"/images/#{image}/read" data-method="post" data-remote="true" + i.fa.fa-trash + + a.button title="Unsubscribe" href=~p"/images/#{image}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_forum.html.slime b/lib/philomena_web/templates/notification/_forum.html.slime deleted file mode 100644 index f7edb198..00000000 --- a/lib/philomena_web/templates/notification/_forum.html.slime +++ /dev/null @@ -1,25 +0,0 @@ -- forum = @notification.actor -- topic = @notification.actor_child - -.flex.flex--centered.flex__grow - div - => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: topic, conn: @conn - => @notification.action - - ' titled - - strong> - = link topic.title, to: ~p"/forums/#{forum}/topics/#{topic}" - - ' in - - => link forum.name, to: ~p"/forums/#{forum}" - - => pretty_time @notification.updated_at - -.flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-trash - - a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-bell-slash \ No newline at end of file diff --git a/lib/philomena_web/templates/notification/_gallery.html.slime b/lib/philomena_web/templates/notification/_gallery.html.slime index 09e3eccc..0192b449 100644 --- a/lib/philomena_web/templates/notification/_gallery.html.slime +++ b/lib/philomena_web/templates/notification/_gallery.html.slime @@ -1,16 +1,18 @@ +- gallery = @notification.gallery + .flex.flex--centered.flex__grow div - => render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: @notification.actor.creator}, conn: @conn - => @notification.action + => render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: gallery.creator}, conn: @conn + ' added images to strong> - = link @notification.actor.title, to: ~p"/galleries/#{@notification.actor}" + = link gallery.title, to: ~p"/galleries/#{gallery}" => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/galleries/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" + a.button.button--separate-right title="Delete" href=~p"/galleries/#{gallery}/read" data-method="post" data-remote="true" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/galleries/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-bell-slash \ No newline at end of file + a.button title="Unsubscribe" href=~p"/galleries/#{gallery}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_image.html.slime b/lib/philomena_web/templates/notification/_image.html.slime index 89814c39..d0007f08 100644 --- a/lib/philomena_web/templates/notification/_image.html.slime +++ b/lib/philomena_web/templates/notification/_image.html.slime @@ -1,19 +1,24 @@ +- target = @notification.target +- source = @notification.source + +.flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right + = render PhilomenaWeb.ImageView, "_image_container.html", image: target, size: :thumb_tiny, conn: @conn + .flex.flex--centered.flex__grow div - = if @notification.actor_child do - => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: @notification.actor_child, conn: @conn - - else - ' Someone - => @notification.action + ' Someone + | merged # + = source.id + ' into strong> - = link "##{@notification.actor_id}", to: ~p"/images/#{@notification.actor}" <> "#comments" + = link "##{target.id}", to: ~p"/images/#{target}" <> "#comments" => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/images/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" + a.button.button--separate-right title="Delete" href=~p"/images/#{target}/read" data-method="post" data-remote="true" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/images/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-bell-slash \ No newline at end of file + a.button title="Unsubscribe" href=~p"/images/#{target}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_notification.html.slime b/lib/philomena_web/templates/notification/_notification.html.slime deleted file mode 100644 index dfc34b18..00000000 --- a/lib/philomena_web/templates/notification/_notification.html.slime +++ /dev/null @@ -1,7 +0,0 @@ -= if @notification.actor do - .block.block--fixed.flex.notification id="notification-#{@notification.id}" - = if @notification.actor_type == "Image" and @notification.actor do - .flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right - = render PhilomenaWeb.ImageView, "_image_container.html", image: @notification.actor, size: :thumb_tiny, conn: @conn - - => render PhilomenaWeb.NotificationView, notification_template_path(@notification.actor_type), notification: @notification, conn: @conn diff --git a/lib/philomena_web/templates/notification/_post.html.slime b/lib/philomena_web/templates/notification/_post.html.slime new file mode 100644 index 00000000..bac4acb9 --- /dev/null +++ b/lib/philomena_web/templates/notification/_post.html.slime @@ -0,0 +1,19 @@ +- topic = @notification.topic +- post = @notification.post + +.flex.flex--centered.flex__grow + div + => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: post, conn: @conn + ' posted a new reply in + + strong> + = link topic.title, to: ~p"/forums/#{topic.forum}/topics/#{topic}?#{[post_id: post.id]}" <> "#post_#{post.id}" + + => pretty_time @notification.updated_at + +.flex.flex--centered.flex--no-wrap + a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" + i.fa.fa-trash + + a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_topic.html.slime b/lib/philomena_web/templates/notification/_topic.html.slime index 5ecefcfd..cf2bd5df 100644 --- a/lib/philomena_web/templates/notification/_topic.html.slime +++ b/lib/philomena_web/templates/notification/_topic.html.slime @@ -1,19 +1,23 @@ -- topic = @notification.actor -- post = @notification.actor_child +- topic = @notification.topic +- forum = topic.forum .flex.flex--centered.flex__grow div - => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: post, conn: @conn - => @notification.action + => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: topic, conn: @conn + ' posted a new topic titled strong> - = link topic.title, to: ~p"/forums/#{topic.forum}/topics/#{topic}?#{[post_id: post.id]}" <> "#post_#{post.id}" + = link topic.title, to: ~p"/forums/#{forum}/topics/#{topic}" + + ' in + + => link forum.name, to: ~p"/forums/#{forum}" => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" + a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/topics/#{topic}/read" data-method="post" data-remote="true" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}" - i.fa.fa-bell-slash \ No newline at end of file + a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" + i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/category/show.html.slime b/lib/philomena_web/templates/notification/category/show.html.slime index 59f2f9d5..a8a39ab5 100644 --- a/lib/philomena_web/templates/notification/category/show.html.slime +++ b/lib/philomena_web/templates/notification/category/show.html.slime @@ -2,18 +2,19 @@ h1 Notification Area .walloftext = cond do - Enum.any?(@notifications) -> - - route = fn p -> ~p"/notifications/categories/#{@type}?#{p}" end + - route = fn p -> ~p"/notifications/categories/#{@category}?#{p}" end - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn .block.notification-type-block .block__header - span.block__header__title = name_of_type(@type) + span.block__header__title = name_of_category(@category) .block__header.block__header__sub = pagination div = for notification <- @notifications do - = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + .block.block--fixed.flex.notification + = render PhilomenaWeb.NotificationView, notification_template_path(@category), notification: notification, conn: @conn .block__header.block__header--light = pagination diff --git a/lib/philomena_web/templates/notification/index.html.slime b/lib/philomena_web/templates/notification/index.html.slime index fa957442..ab6b4a28 100644 --- a/lib/philomena_web/templates/notification/index.html.slime +++ b/lib/philomena_web/templates/notification/index.html.slime @@ -1,22 +1,22 @@ h1 Notification Area .walloftext - = cond do - - Enum.any?(@notifications) -> - = for {type, notifications} <- @notifications do - .block.notification-type-block - .block__header - span.block__header__title = name_of_type(type) + = for {category, notifications} <- @notifications, Enum.any?(notifications) do + .block.notification-type-block + .block__header + span.block__header__title = name_of_category(category) - div - = for notification <- notifications do - = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + div + = for notification <- notifications do + .block.block--fixed.flex.notification + = render PhilomenaWeb.NotificationView, notification_template_path(category), notification: notification, conn: @conn - .block__header.block__header--light - a href=~p"/notifications/categories/#{type}" - | View category + .block__header.block__header--light + a href=~p"/notifications/categories/#{category}" + | View category ( + = notifications.total_entries + | ) - - true -> - p - ' To get notifications on new comments and forum posts, click the - ' 'Subscribe' button in the bar at the top of an image or forum topic. - ' You'll get notifications here for any new posts or comments. + p + ' To get notifications on new comments and forum posts, click the + ' 'Subscribe' button in the bar at the top of an image or forum topic. + ' You'll get notifications here for any new posts or comments. diff --git a/lib/philomena_web/views/notification/category_view.ex b/lib/philomena_web/views/notification/category_view.ex index 148d94f5..8c6717a6 100644 --- a/lib/philomena_web/views/notification/category_view.ex +++ b/lib/philomena_web/views/notification/category_view.ex @@ -1,5 +1,6 @@ defmodule PhilomenaWeb.Notification.CategoryView do use PhilomenaWeb, :view - defdelegate name_of_type(type), to: PhilomenaWeb.NotificationView + defdelegate name_of_category(category), to: PhilomenaWeb.NotificationView + defdelegate notification_template_path(category), to: PhilomenaWeb.NotificationView end diff --git a/lib/philomena_web/views/notification_view.ex b/lib/philomena_web/views/notification_view.ex index dcaf81dd..5d30e4d9 100644 --- a/lib/philomena_web/views/notification_view.ex +++ b/lib/philomena_web/views/notification_view.ex @@ -2,20 +2,20 @@ defmodule PhilomenaWeb.NotificationView do use PhilomenaWeb, :view @template_paths %{ - "Channel" => "_channel.html", - "Forum" => "_forum.html", - "Gallery" => "_gallery.html", - "Image" => "_image.html", - "LivestreamChannel" => "_channel.html", - "Topic" => "_topic.html" + "channel_live" => "_channel.html", + "forum_post" => "_post.html", + "forum_topic" => "_topic.html", + "gallery_image" => "_gallery.html", + "image_comment" => "_comment.html", + "image_merge" => "_image.html" } - def notification_template_path(actor_type) do - @template_paths[actor_type] + def notification_template_path(category) do + @template_paths[to_string(category)] end - def name_of_type(notification_type) do - case notification_type do + def name_of_category(category) do + case category do :channel_live -> "Live channels" From 40fa0331b12fe72fa0d2754dd970b279cd9fd632 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 08:54:42 -0400 Subject: [PATCH 046/115] Fixup --- lib/philomena/notifications.ex | 4 ++-- lib/philomena/notifications/category.ex | 6 +++--- lib/philomena_web/templates/notification/_image.html.slime | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index ea0b6029..cbed7413 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -41,14 +41,14 @@ defmodule Philomena.Notifications do ## Examples iex> unread_notifications_for_user(user, page_size: 10) - %{ + [ channel_live: [], forum_post: [%ForumPostNotification{...}, ...], forum_topic: [%ForumTopicNotification{...}, ...], gallery_image: [], image_comment: [%ImageCommentNotification{...}, ...], image_merge: [] - } + ] """ def unread_notifications_for_user(user, pagination) do diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex index 249dd838..b16649c0 100644 --- a/lib/philomena/notifications/category.ex +++ b/lib/philomena/notifications/category.ex @@ -67,18 +67,18 @@ defmodule Philomena.Notifications.Category do ## Examples iex> unread_notifications_for_user(user, page_size: 10) - %{ + [ channel_live: [], forum_post: [%ForumPostNotification{...}, ...], forum_topic: [%ForumTopicNotification{...}, ...], gallery_image: [], image_comment: [%ImageCommentNotification{...}, ...], image_merge: [] - } + ] """ def unread_notifications_for_user(user, pagination) do - Map.new(categories(), fn category -> + Enum.map(categories(), fn category -> results = category |> query_for_category_and_user(user) diff --git a/lib/philomena_web/templates/notification/_image.html.slime b/lib/philomena_web/templates/notification/_image.html.slime index d0007f08..01f72dd9 100644 --- a/lib/philomena_web/templates/notification/_image.html.slime +++ b/lib/philomena_web/templates/notification/_image.html.slime @@ -8,7 +8,7 @@ div ' Someone | merged # - = source.id + => source.id ' into strong> From 0b93a1814adcd3180de57c63e639cc37dd5c7650 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 11:26:11 -0400 Subject: [PATCH 047/115] Fix wrong awarding user for automatic artist badge award --- lib/philomena/artist_links.ex | 2 +- lib/philomena/artist_links/badge_awarder.ex | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/philomena/artist_links.ex b/lib/philomena/artist_links.ex index 9a9de4a6..28f468a9 100644 --- a/lib/philomena/artist_links.ex +++ b/lib/philomena/artist_links.ex @@ -93,7 +93,7 @@ defmodule Philomena.ArtistLinks do Multi.new() |> Multi.update(:artist_link, artist_link_changeset) - |> Multi.run(:add_award, fn _repo, _changes -> BadgeAwarder.award_badge(artist_link) end) + |> Multi.run(:add_award, BadgeAwarder.award_callback(artist_link, verifying_user)) |> Repo.transaction() |> case do {:ok, %{artist_link: artist_link}} -> diff --git a/lib/philomena/artist_links/badge_awarder.ex b/lib/philomena/artist_links/badge_awarder.ex index ae231c74..275a5aee 100644 --- a/lib/philomena/artist_links/badge_awarder.ex +++ b/lib/philomena/artist_links/badge_awarder.ex @@ -16,13 +16,22 @@ defmodule Philomena.ArtistLinks.BadgeAwarder do Returns `{:ok, award}`, `{:ok, nil}`, or `{:error, changeset}`. The return value is suitable for use as the return value to an `Ecto.Multi.run/3` callback. """ - def award_badge(artist_link) do + def award_badge(artist_link, verifying_user) do with badge when not is_nil(badge) <- Badges.get_badge_by_title(@badge_title), award when is_nil(award) <- Badges.get_badge_award_for(badge, artist_link.user) do - Badges.create_badge_award(artist_link.user, artist_link.user, %{badge_id: badge.id}) + Badges.create_badge_award(verifying_user, artist_link.user, %{badge_id: badge.id}) else _ -> {:ok, nil} end end + + @doc """ + Get a callback for issuing a badge award from within an `m:Ecto.Multi`. + """ + def award_callback(artist_link, verifying_user) do + fn _repo, _changes -> + award_badge(artist_link, verifying_user) + end + end end From f9e8911411569d0ca622a1b7234d8de75deabe36 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 11:27:04 -0400 Subject: [PATCH 048/115] Fix mod notes again --- lib/philomena_web/controllers/dnp_entry_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/philomena_web/controllers/dnp_entry_controller.ex b/lib/philomena_web/controllers/dnp_entry_controller.ex index a71407ac..49ca7e94 100644 --- a/lib/philomena_web/controllers/dnp_entry_controller.ex +++ b/lib/philomena_web/controllers/dnp_entry_controller.ex @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.DnpEntryController do alias PhilomenaWeb.MarkdownRenderer alias Philomena.DnpEntries alias Philomena.Tags.Tag + alias Philomena.ModNotes.ModNote alias Philomena.ModNotes alias Philomena.Repo import Ecto.Query From 7a9994a1e71ae34ae4a55ab4a7aadcdc5769278c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 15:12:45 -0400 Subject: [PATCH 049/115] Remove IE11 FormData workaround https://blog.yorkxin.org/posts/ajax-with-formdata-is-broken-on-ie10-ie11/ IE11 support is long gone and so is the browser. --- lib/philomena_web/templates/tag/_tag_editor.html.slime | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime index 0bb692fe..a45aecf5 100644 --- a/lib/philomena_web/templates/tag/_tag_editor.html.slime +++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime @@ -11,7 +11,6 @@ elixir: .js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}" input.input class="js-taginput-input js-taginput-#{@name}" id="taginput-fancy-#{@name}" type="text" placeholder="add a tag" autocomplete="off" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" button.button.button--state-primary.button--bold class="js-taginput-show" data-click-show=".js-taginput-fancy,.js-taginput-hide" data-click-hide=".js-taginput-plain,.js-taginput-show" data-click-focus=".js-taginput-input.js-taginput-#{@name}" - = hidden_input :fuck_ie, :fuck_ie, value: "fuck_ie" ' Fancy Editor button.hidden.button.button--state-primary.button--bold class="js-taginput-hide" data-click-show=".js-taginput-plain,.js-taginput-show" data-click-hide=".js-taginput-fancy,.js-taginput-hide" data-click-focus=".js-taginput-plain.js-taginput-#{@name}" ' Plain Editor From 7d432c51140ef72f2d356502b12a38f46a0bcf6f Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 16:07:08 -0400 Subject: [PATCH 050/115] Fix merge error --- lib/philomena/images.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 3aefa4c2..92c02155 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -912,8 +912,8 @@ defmodule Philomena.Images do {merge_notification_count, nil} = ImageMergeNotification - |> where(image_id: ^source.id) - |> Repo.update_all(set: [image_id: target.id]) + |> where(target_id: ^source.id) + |> Repo.update_all(set: [target_id: target.id]) {:ok, {comment_notification_count, merge_notification_count}} end From 3d69807118d523e9ba8b15ebb6b5091037bd4fe9 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 19:13:08 -0400 Subject: [PATCH 051/115] Avoid creating notifications for user performing the action --- lib/philomena/comments.ex | 12 ++++------ lib/philomena/notifications.ex | 27 ++++++++++++++-------- lib/philomena/notifications/creator.ex | 32 ++++++++++++++++++-------- lib/philomena/posts.ex | 12 ++++------ lib/philomena/topics.ex | 7 ++++-- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 281de6c0..4498dca8 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -72,14 +72,12 @@ defmodule Philomena.Comments do end def perform_notify(comment_id) do - comment = get_comment!(comment_id) + comment = + comment_id + |> get_comment!() + |> Repo.preload([:user, :image]) - image = - comment - |> Repo.preload(:image) - |> Map.fetch!(:image) - - Notifications.create_image_comment_notification(image, comment) + Notifications.create_image_comment_notification(comment.user, comment.image, comment) end @doc """ diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index cbed7413..f441c24f 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -78,7 +78,7 @@ defmodule Philomena.Notifications do """ def create_channel_live_notification(channel) do - Creator.create_single(ChannelSubscription, ChannelLiveNotification, :channel_id, channel) + Creator.create_single(ChannelSubscription, ChannelLiveNotification, nil, :channel_id, channel) end @doc """ @@ -86,14 +86,15 @@ defmodule Philomena.Notifications do ## Examples - iex> create_forum_post_notification(topic, post) + iex> create_forum_post_notification(user, topic, post) {:ok, 2} """ - def create_forum_post_notification(topic, post) do + def create_forum_post_notification(user, topic, post) do Creator.create_double( TopicSubscription, ForumPostNotification, + user, :topic_id, topic, :post_id, @@ -106,12 +107,12 @@ defmodule Philomena.Notifications do ## Examples - iex> create_forum_topic_notification(topic) + iex> create_forum_topic_notification(user, topic) {:ok, 2} """ - def create_forum_topic_notification(topic) do - Creator.create_single(ForumSubscription, ForumTopicNotification, :topic_id, topic) + def create_forum_topic_notification(user, topic) do + Creator.create_single(ForumSubscription, ForumTopicNotification, user, :topic_id, topic) end @doc """ @@ -124,7 +125,13 @@ defmodule Philomena.Notifications do """ def create_gallery_image_notification(gallery) do - Creator.create_single(GallerySubscription, GalleryImageNotification, :gallery_id, gallery) + Creator.create_single( + GallerySubscription, + GalleryImageNotification, + nil, + :gallery_id, + gallery + ) end @doc """ @@ -132,14 +139,15 @@ defmodule Philomena.Notifications do ## Examples - iex> create_image_comment_notification(image, comment) + iex> create_image_comment_notification(user, image, comment) {:ok, 2} """ - def create_image_comment_notification(image, comment) do + def create_image_comment_notification(user, image, comment) do Creator.create_double( ImageSubscription, ImageCommentNotification, + user, :image_id, image, :comment_id, @@ -160,6 +168,7 @@ defmodule Philomena.Notifications do Creator.create_double( ImageSubscription, ImageMergeNotification, + nil, :target_id, target, :source_id, diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex index 5a04b724..95dd25cd 100644 --- a/lib/philomena/notifications/creator.ex +++ b/lib/philomena/notifications/creator.ex @@ -22,13 +22,13 @@ defmodule Philomena.Notifications.Creator do ## Example - iex> create_single(GallerySubscription, GalleryImageNotification, :gallery_id, gallery) + iex> create_single(GallerySubscription, GalleryImageNotification, nil, :gallery_id, gallery) {:ok, 2} """ - def create_single(subscription, notification, name, object) do + def create_single(subscription, notification, user, name, object) do subscription - |> create_notification_query(name, object) + |> create_notification_query(user, name, object) |> create_notification(notification, name) end @@ -45,6 +45,7 @@ defmodule Philomena.Notifications.Creator do iex> create_double( ...> ImageSubscription, ...> ImageCommentNotification, + ...> user, ...> :image_id, ...> image, ...> :comment_id, @@ -53,9 +54,9 @@ defmodule Philomena.Notifications.Creator do {:ok, 2} """ - def create_double(subscription, notification, name1, object1, name2, object2) do + def create_double(subscription, notification, user, name1, object1, name2, object2) do subscription - |> create_notification_query(name1, object1, name2, object2) + |> create_notification_query(user, name1, object1, name2, object2) |> create_notification(notification, name1) end @@ -80,10 +81,10 @@ defmodule Philomena.Notifications.Creator do # TODO: the following cannot be accomplished with a single query expression # due to this Ecto bug: https://github.com/elixir-ecto/ecto/issues/4430 - defp create_notification_query(subscription, name, object) do + defp create_notification_query(subscription, user, name, object) do now = DateTime.utc_now(:second) - from s in subscription, + from s in subscription_query(subscription, user), where: field(s, ^name) == ^object.id, select: %{ ^name => type(^object.id, :integer), @@ -94,10 +95,10 @@ defmodule Philomena.Notifications.Creator do } end - defp create_notification_query(subscription, name1, object1, name2, object2) do + defp create_notification_query(subscription, user, name1, object1, name2, object2) do now = DateTime.utc_now(:second) - from s in subscription, + from s in subscription_query(subscription, user), where: field(s, ^name1) == ^object1.id, select: %{ ^name1 => type(^object1.id, :integer), @@ -109,6 +110,19 @@ defmodule Philomena.Notifications.Creator do } end + defp subscription_query(subscription, user) do + case user do + %{id: user_id} -> + # Avoid sending notifications to the user which performed the action. + from s in subscription, + where: s.user_id != ^user_id + + _ -> + # When not created by a user, send notifications to all subscribers. + subscription + end + end + defp create_notification(query, notification, name) do {count, nil} = Repo.insert_all( diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 2de6cfbb..af29c2e3 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -121,14 +121,12 @@ defmodule Philomena.Posts do end def perform_notify(post_id) do - post = get_post!(post_id) + post = + post_id + |> get_post!() + |> Repo.preload([:user, :topic]) - topic = - post - |> Repo.preload(:topic) - |> Map.fetch!(:topic) - - Notifications.create_forum_post_notification(topic, post) + Notifications.create_forum_post_notification(post.user, post.topic, post) end @doc """ diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 3ff4aba5..02524ea3 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -91,9 +91,12 @@ defmodule Philomena.Topics do end def perform_notify([topic_id, _post_id]) do - topic = get_topic!(topic_id) + topic = + topic_id + |> get_topic!() + |> Repo.preload(:user) - Notifications.create_forum_topic_notification(topic) + Notifications.create_forum_topic_notification(topic.user, topic) end @doc """ From 3f832e89f610d252e3a144e62200e2e69120b4a2 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 19:27:14 -0400 Subject: [PATCH 052/115] Fix topic creation notifications --- lib/philomena/notifications.ex | 24 ++++++++++++++++++------ lib/philomena/notifications/creator.ex | 12 ++++++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index f441c24f..baf2f8be 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -78,7 +78,13 @@ defmodule Philomena.Notifications do """ def create_channel_live_notification(channel) do - Creator.create_single(ChannelSubscription, ChannelLiveNotification, nil, :channel_id, channel) + Creator.create_single( + where(ChannelSubscription, channel_id: ^channel.id), + ChannelLiveNotification, + nil, + :channel_id, + channel + ) end @doc """ @@ -92,7 +98,7 @@ defmodule Philomena.Notifications do """ def create_forum_post_notification(user, topic, post) do Creator.create_double( - TopicSubscription, + where(TopicSubscription, topic_id: ^topic.id), ForumPostNotification, user, :topic_id, @@ -112,7 +118,13 @@ defmodule Philomena.Notifications do """ def create_forum_topic_notification(user, topic) do - Creator.create_single(ForumSubscription, ForumTopicNotification, user, :topic_id, topic) + Creator.create_single( + where(ForumSubscription, forum_id: ^topic.forum_id), + ForumTopicNotification, + user, + :topic_id, + topic + ) end @doc """ @@ -126,7 +138,7 @@ defmodule Philomena.Notifications do """ def create_gallery_image_notification(gallery) do Creator.create_single( - GallerySubscription, + where(GallerySubscription, gallery_id: ^gallery.id), GalleryImageNotification, nil, :gallery_id, @@ -145,7 +157,7 @@ defmodule Philomena.Notifications do """ def create_image_comment_notification(user, image, comment) do Creator.create_double( - ImageSubscription, + where(ImageSubscription, image_id: ^image.id), ImageCommentNotification, user, :image_id, @@ -166,7 +178,7 @@ defmodule Philomena.Notifications do """ def create_image_merge_notification(target, source) do Creator.create_double( - ImageSubscription, + where(ImageSubscription, image_id: ^target.id), ImageMergeNotification, nil, :target_id, diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex index 95dd25cd..ee4fec6a 100644 --- a/lib/philomena/notifications/creator.ex +++ b/lib/philomena/notifications/creator.ex @@ -22,7 +22,13 @@ defmodule Philomena.Notifications.Creator do ## Example - iex> create_single(GallerySubscription, GalleryImageNotification, nil, :gallery_id, gallery) + iex> create_single( + ...> where(GallerySubscription, gallery_id: ^gallery.id), + ...> GalleryImageNotification, + ...> nil, + ...> :gallery_id, + ...> gallery + ...> ) {:ok, 2} """ @@ -43,7 +49,7 @@ defmodule Philomena.Notifications.Creator do ## Example iex> create_double( - ...> ImageSubscription, + ...> where(ImageSubscription, image_id: ^image.id), ...> ImageCommentNotification, ...> user, ...> :image_id, @@ -85,7 +91,6 @@ defmodule Philomena.Notifications.Creator do now = DateTime.utc_now(:second) from s in subscription_query(subscription, user), - where: field(s, ^name) == ^object.id, select: %{ ^name => type(^object.id, :integer), user_id: s.user_id, @@ -99,7 +104,6 @@ defmodule Philomena.Notifications.Creator do now = DateTime.utc_now(:second) from s in subscription_query(subscription, user), - where: field(s, ^name1) == ^object1.id, select: %{ ^name1 => type(^object1.id, :integer), ^name2 => type(^object2.id, :integer), From c30406bcca1a3905d0da7cecf4ec4bd4df61433c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 19:51:25 -0400 Subject: [PATCH 053/115] Fix notification dismissal --- assets/js/boorujs.js | 7 +++++++ .../templates/notification/_channel.html.slime | 4 ++-- .../templates/notification/_comment.html.slime | 4 ++-- .../templates/notification/_gallery.html.slime | 4 ++-- lib/philomena_web/templates/notification/_image.html.slime | 4 ++-- lib/philomena_web/templates/notification/_post.html.slime | 4 ++-- lib/philomena_web/templates/notification/_topic.html.slime | 4 ++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/assets/js/boorujs.js b/assets/js/boorujs.js index 9d8a71c6..86d9901b 100644 --- a/assets/js/boorujs.js +++ b/assets/js/boorujs.js @@ -52,6 +52,13 @@ const actions = { addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName); }, + hideParent(data) { + const base = data.el.closest(data.value); + if (base) { + base.classList.add('hidden'); + } + }, + tab(data) { const block = data.el.parentNode.parentNode, newTab = $(`.block__tab[data-tab="${data.value}"]`), diff --git a/lib/philomena_web/templates/notification/_channel.html.slime b/lib/philomena_web/templates/notification/_channel.html.slime index 0c4c5b73..c22299db 100644 --- a/lib/philomena_web/templates/notification/_channel.html.slime +++ b/lib/philomena_web/templates/notification/_channel.html.slime @@ -7,8 +7,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.channel}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.channel}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/channels/#{@notification.channel}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/channels/#{@notification.channel}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_comment.html.slime b/lib/philomena_web/templates/notification/_comment.html.slime index 4e9efeb6..076ccb34 100644 --- a/lib/philomena_web/templates/notification/_comment.html.slime +++ b/lib/philomena_web/templates/notification/_comment.html.slime @@ -15,8 +15,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/images/#{image}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/images/#{image}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/images/#{image}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/images/#{image}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_gallery.html.slime b/lib/philomena_web/templates/notification/_gallery.html.slime index 0192b449..8ef024d0 100644 --- a/lib/philomena_web/templates/notification/_gallery.html.slime +++ b/lib/philomena_web/templates/notification/_gallery.html.slime @@ -11,8 +11,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/galleries/#{gallery}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/galleries/#{gallery}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/galleries/#{gallery}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/galleries/#{gallery}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_image.html.slime b/lib/philomena_web/templates/notification/_image.html.slime index 01f72dd9..dcfad4eb 100644 --- a/lib/philomena_web/templates/notification/_image.html.slime +++ b/lib/philomena_web/templates/notification/_image.html.slime @@ -17,8 +17,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/images/#{target}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/images/#{target}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/images/#{target}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/images/#{target}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_post.html.slime b/lib/philomena_web/templates/notification/_post.html.slime index bac4acb9..f0dc0b66 100644 --- a/lib/philomena_web/templates/notification/_post.html.slime +++ b/lib/philomena_web/templates/notification/_post.html.slime @@ -12,8 +12,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash diff --git a/lib/philomena_web/templates/notification/_topic.html.slime b/lib/philomena_web/templates/notification/_topic.html.slime index cf2bd5df..ff84ca3e 100644 --- a/lib/philomena_web/templates/notification/_topic.html.slime +++ b/lib/philomena_web/templates/notification/_topic.html.slime @@ -16,8 +16,8 @@ => pretty_time @notification.updated_at .flex.flex--centered.flex--no-wrap - a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/topics/#{topic}/read" data-method="post" data-remote="true" + a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" + a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" i.fa.fa-bell-slash From c7f618d9dd5546ce0f36ddca7f5193173a096fd5 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 20:03:49 -0400 Subject: [PATCH 054/115] Clear notifications when subscription is removed --- lib/philomena/channels.ex | 1 + lib/philomena/galleries.ex | 1 + lib/philomena/images.ex | 1 + lib/philomena/subscriptions.ex | 14 ++++++++++++++ lib/philomena/topics.ex | 1 + .../templates/notification/_topic.html.slime | 3 --- 6 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index 2ffd4ce4..efa6d47c 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -12,6 +12,7 @@ defmodule Philomena.Channels do alias Philomena.Tags use Philomena.Subscriptions, + on_delete: :clear_channel_notification, id_name: :channel_id @doc """ diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index 4c76df35..c553ad3e 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -19,6 +19,7 @@ defmodule Philomena.Galleries do alias Philomena.Images use Philomena.Subscriptions, + on_delete: :clear_gallery_notification, id_name: :gallery_id @doc """ diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 92c02155..4ebc215a 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -39,6 +39,7 @@ defmodule Philomena.Images do alias Philomena.Users.User use Philomena.Subscriptions, + on_delete: :clear_image_notification, id_name: :image_id @doc """ diff --git a/lib/philomena/subscriptions.ex b/lib/philomena/subscriptions.ex index 8e67183f..d0688107 100644 --- a/lib/philomena/subscriptions.ex +++ b/lib/philomena/subscriptions.ex @@ -25,6 +25,18 @@ defmodule Philomena.Subscriptions do # For Philomena.Images, this yields :image_id field_name = Keyword.fetch!(opts, :id_name) + # Deletion callback + on_delete = + case Keyword.get(opts, :on_delete) do + nil -> + [] + + callback when is_atom(callback) -> + quote do + apply(__MODULE__, unquote(callback), [object, user]) + end + end + # For Philomena.Images, this yields Philomena.Images.Subscription subscription_module = Module.concat(__CALLER__.module, Subscription) @@ -100,6 +112,8 @@ defmodule Philomena.Subscriptions do """ def delete_subscription(object, user) do + unquote(on_delete) + Philomena.Subscriptions.delete_subscription( unquote(subscription_module), unquote(field_name), diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 02524ea3..e1e5af29 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -14,6 +14,7 @@ defmodule Philomena.Topics do alias Philomena.NotificationWorker use Philomena.Subscriptions, + on_delete: :clear_topic_notification, id_name: :topic_id @doc """ diff --git a/lib/philomena_web/templates/notification/_topic.html.slime b/lib/philomena_web/templates/notification/_topic.html.slime index ff84ca3e..37f6786d 100644 --- a/lib/philomena_web/templates/notification/_topic.html.slime +++ b/lib/philomena_web/templates/notification/_topic.html.slime @@ -18,6 +18,3 @@ .flex.flex--centered.flex--no-wrap a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-click-hideparent=".notification" i.fa.fa-trash - - a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification" - i.fa.fa-bell-slash From 91e1af6402295939f4139d9b217c8fd1b098c7d3 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 20:44:38 -0400 Subject: [PATCH 055/115] Fix gallery deletion --- lib/philomena/galleries.ex | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index c553ad3e..65516064 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -15,7 +15,6 @@ defmodule Philomena.Galleries do alias Philomena.GalleryReorderWorker alias Philomena.Notifications alias Philomena.NotificationWorker - alias Philomena.Notifications.{Notification, UnreadNotification} alias Philomena.Images use Philomena.Subscriptions, @@ -95,21 +94,8 @@ defmodule Philomena.Galleries do |> select([i], i.image_id) |> Repo.all() - unread_notifications = - UnreadNotification - |> join(:inner, [un], _ in assoc(un, :notification)) - |> where([_, n], n.actor_type == "Gallery") - |> where([_, n], n.actor_id == ^gallery.id) - - notifications = - Notification - |> where(actor_type: "Gallery") - |> where(actor_id: ^gallery.id) - Multi.new() |> Multi.delete(:gallery, gallery) - |> Multi.delete_all(:unread_notifications, unread_notifications) - |> Multi.delete_all(:notifications, notifications) |> Repo.transaction() |> case do {:ok, %{gallery: gallery}} -> From 0891fe31afd3525572649f4d8407878d1a2c9bdc Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Jul 2024 00:13:04 -0400 Subject: [PATCH 056/115] Use insert_all instead of update_all for conflict resolution during merges --- lib/philomena/images.ex | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 4ebc215a..326ede2d 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -906,15 +906,38 @@ defmodule Philomena.Images do Repo.insert_all(Subscription, subscriptions, on_conflict: :nothing) + comment_notifications = + from cn in ImageCommentNotification, + where: cn.image_id == ^source.id, + select: %{ + user_id: cn.user_id, + image_id: ^target.id, + comment_id: cn.comment_id, + read: cn.read, + created_at: cn.created_at, + updated_at: cn.updated_at + } + + merge_notifications = + from mn in ImageMergeNotification, + where: mn.target_id == ^source.id, + select: %{ + user_id: mn.user_id, + target_id: ^target.id, + source_id: mn.source_id, + read: mn.read, + created_at: mn.created_at, + updated_at: mn.updated_at + } + {comment_notification_count, nil} = - ImageCommentNotification - |> where(image_id: ^source.id) - |> Repo.update_all(set: [image_id: target.id]) + Repo.insert_all(ImageCommentNotification, comment_notifications, on_conflict: :nothing) {merge_notification_count, nil} = - ImageMergeNotification - |> where(target_id: ^source.id) - |> Repo.update_all(set: [target_id: target.id]) + Repo.insert_all(ImageMergeNotification, merge_notifications, on_conflict: :nothing) + + Repo.delete_all(exclude(comment_notifications, :select)) + Repo.delete_all(exclude(merge_notifications, :select)) {:ok, {comment_notification_count, merge_notification_count}} end From f91d9f2143fc3ff3e28ff52519c11201df08974c Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Jul 2024 12:35:47 -0400 Subject: [PATCH 057/115] Remove unused changeset functions --- lib/philomena/notifications/channel_live_notification.ex | 8 -------- lib/philomena/notifications/forum_post_notification.ex | 8 -------- lib/philomena/notifications/forum_topic_notification.ex | 8 -------- lib/philomena/notifications/gallery_image_notification.ex | 8 -------- lib/philomena/notifications/image_comment_notification.ex | 8 -------- lib/philomena/notifications/image_merge_notification.ex | 8 -------- 6 files changed, 48 deletions(-) diff --git a/lib/philomena/notifications/channel_live_notification.ex b/lib/philomena/notifications/channel_live_notification.ex index b60fb8e6..109c784f 100644 --- a/lib/philomena/notifications/channel_live_notification.ex +++ b/lib/philomena/notifications/channel_live_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.ChannelLiveNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Channels.Channel @@ -15,11 +14,4 @@ defmodule Philomena.Notifications.ChannelLiveNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(channel_live_notification, attrs) do - channel_live_notification - |> cast(attrs, []) - |> validate_required([]) - end end diff --git a/lib/philomena/notifications/forum_post_notification.ex b/lib/philomena/notifications/forum_post_notification.ex index 0d2ad20a..f0313628 100644 --- a/lib/philomena/notifications/forum_post_notification.ex +++ b/lib/philomena/notifications/forum_post_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.ForumPostNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Topics.Topic @@ -17,11 +16,4 @@ defmodule Philomena.Notifications.ForumPostNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(forum_post_notification, attrs) do - forum_post_notification - |> cast(attrs, []) - |> validate_required([]) - end end diff --git a/lib/philomena/notifications/forum_topic_notification.ex b/lib/philomena/notifications/forum_topic_notification.ex index 862f42ae..2ff39d38 100644 --- a/lib/philomena/notifications/forum_topic_notification.ex +++ b/lib/philomena/notifications/forum_topic_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.ForumTopicNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Topics.Topic @@ -15,11 +14,4 @@ defmodule Philomena.Notifications.ForumTopicNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(forum_topic_notification, attrs) do - forum_topic_notification - |> cast(attrs, []) - |> validate_required([]) - end end diff --git a/lib/philomena/notifications/gallery_image_notification.ex b/lib/philomena/notifications/gallery_image_notification.ex index 1d00d7c9..816de3ed 100644 --- a/lib/philomena/notifications/gallery_image_notification.ex +++ b/lib/philomena/notifications/gallery_image_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.GalleryImageNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Galleries.Gallery @@ -15,11 +14,4 @@ defmodule Philomena.Notifications.GalleryImageNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(gallery_image_notification, attrs) do - gallery_image_notification - |> cast(attrs, []) - |> validate_required([]) - end end diff --git a/lib/philomena/notifications/image_comment_notification.ex b/lib/philomena/notifications/image_comment_notification.ex index 08a2ddff..28487d9d 100644 --- a/lib/philomena/notifications/image_comment_notification.ex +++ b/lib/philomena/notifications/image_comment_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.ImageCommentNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Images.Image @@ -17,11 +16,4 @@ defmodule Philomena.Notifications.ImageCommentNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(image_comment_notification, attrs) do - image_comment_notification - |> cast(attrs, []) - |> validate_required([]) - end end diff --git a/lib/philomena/notifications/image_merge_notification.ex b/lib/philomena/notifications/image_merge_notification.ex index 5546707e..e767ffbd 100644 --- a/lib/philomena/notifications/image_merge_notification.ex +++ b/lib/philomena/notifications/image_merge_notification.ex @@ -1,6 +1,5 @@ defmodule Philomena.Notifications.ImageMergeNotification do use Ecto.Schema - import Ecto.Changeset alias Philomena.Users.User alias Philomena.Images.Image @@ -16,11 +15,4 @@ defmodule Philomena.Notifications.ImageMergeNotification do timestamps(inserted_at: :created_at, type: :utc_datetime) end - - @doc false - def changeset(image_merge_notification, attrs) do - image_merge_notification - |> cast(attrs, []) - |> validate_required([]) - end end From 9538575c975277c4c8d3b515951e1fb1a467ae3a Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Jul 2024 13:19:23 -0400 Subject: [PATCH 058/115] Streamline notification broadcasts --- lib/philomena/notifications.ex | 106 ++++++++++++----------- lib/philomena/notifications/creator.ex | 115 +++++++++---------------- 2 files changed, 97 insertions(+), 124 deletions(-) diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index baf2f8be..1adae26e 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -4,6 +4,7 @@ defmodule Philomena.Notifications do """ import Ecto.Query, warn: false + alias Philomena.Repo alias Philomena.Channels.Subscription, as: ChannelSubscription alias Philomena.Forums.Subscription, as: ForumSubscription @@ -78,12 +79,11 @@ defmodule Philomena.Notifications do """ def create_channel_live_notification(channel) do - Creator.create_single( - where(ChannelSubscription, channel_id: ^channel.id), - ChannelLiveNotification, - nil, - :channel_id, - channel + Creator.broadcast_notification( + from: {ChannelSubscription, channel_id: channel.id}, + into: ChannelLiveNotification, + select: [channel_id: channel.id], + unique_key: :channel_id ) end @@ -97,14 +97,12 @@ defmodule Philomena.Notifications do """ def create_forum_post_notification(user, topic, post) do - Creator.create_double( - where(TopicSubscription, topic_id: ^topic.id), - ForumPostNotification, - user, - :topic_id, - topic, - :post_id, - post + Creator.broadcast_notification( + notification_author: user, + from: {TopicSubscription, topic_id: topic.id}, + into: ForumPostNotification, + select: [topic_id: topic.id, post_id: post.id], + unique_key: :topic_id ) end @@ -118,12 +116,12 @@ defmodule Philomena.Notifications do """ def create_forum_topic_notification(user, topic) do - Creator.create_single( - where(ForumSubscription, forum_id: ^topic.forum_id), - ForumTopicNotification, - user, - :topic_id, - topic + Creator.broadcast_notification( + notification_author: user, + from: {ForumSubscription, forum_id: topic.forum_id}, + into: ForumTopicNotification, + select: [topic_id: topic.id], + unique_key: :topic_id ) end @@ -137,12 +135,11 @@ defmodule Philomena.Notifications do """ def create_gallery_image_notification(gallery) do - Creator.create_single( - where(GallerySubscription, gallery_id: ^gallery.id), - GalleryImageNotification, - nil, - :gallery_id, - gallery + Creator.broadcast_notification( + from: {GallerySubscription, gallery_id: gallery.id}, + into: GalleryImageNotification, + select: [gallery_id: gallery.id], + unique_key: :gallery_id ) end @@ -156,14 +153,12 @@ defmodule Philomena.Notifications do """ def create_image_comment_notification(user, image, comment) do - Creator.create_double( - where(ImageSubscription, image_id: ^image.id), - ImageCommentNotification, - user, - :image_id, - image, - :comment_id, - comment + Creator.broadcast_notification( + notification_author: user, + from: {ImageSubscription, image_id: image.id}, + into: ImageCommentNotification, + select: [image_id: image.id, comment_id: comment.id], + unique_key: :image_id ) end @@ -177,14 +172,11 @@ defmodule Philomena.Notifications do """ def create_image_merge_notification(target, source) do - Creator.create_double( - where(ImageSubscription, image_id: ^target.id), - ImageMergeNotification, - nil, - :target_id, - target, - :source_id, - source + Creator.broadcast_notification( + from: {ImageSubscription, image_id: target.id}, + into: ImageMergeNotification, + select: [target_id: target.id, source_id: source.id], + unique_key: :target_id ) end @@ -201,7 +193,7 @@ defmodule Philomena.Notifications do def clear_channel_live_notification(channel, user) do ChannelLiveNotification |> where(channel_id: ^channel.id) - |> Creator.clear(user) + |> delete_all_for_user(user) end @doc """ @@ -217,7 +209,7 @@ defmodule Philomena.Notifications do def clear_forum_post_notification(topic, user) do ForumPostNotification |> where(topic_id: ^topic.id) - |> Creator.clear(user) + |> delete_all_for_user(user) end @doc """ @@ -233,7 +225,7 @@ defmodule Philomena.Notifications do def clear_forum_topic_notification(topic, user) do ForumTopicNotification |> where(topic_id: ^topic.id) - |> Creator.clear(user) + |> delete_all_for_user(user) end @doc """ @@ -249,7 +241,7 @@ defmodule Philomena.Notifications do def clear_gallery_image_notification(gallery, user) do GalleryImageNotification |> where(gallery_id: ^gallery.id) - |> Creator.clear(user) + |> delete_all_for_user(user) end @doc """ @@ -265,7 +257,7 @@ defmodule Philomena.Notifications do def clear_image_comment_notification(image, user) do ImageCommentNotification |> where(image_id: ^image.id) - |> Creator.clear(user) + |> delete_all_for_user(user) end @doc """ @@ -281,6 +273,24 @@ defmodule Philomena.Notifications do def clear_image_merge_notification(image, user) do ImageMergeNotification |> where(target_id: ^image.id) - |> Creator.clear(user) + |> delete_all_for_user(user) + end + + # + # Clear all unread notifications using the given query. + # + # Returns `{:ok, count}`, where `count` is the number of affected rows. + # + defp delete_all_for_user(query, user) do + if user do + {count, nil} = + query + |> where(user_id: ^user.id) + |> Repo.delete_all() + + {:ok, count} + else + {:ok, 0} + end end end diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex index ee4fec6a..1ceec14f 100644 --- a/lib/philomena/notifications/creator.ex +++ b/lib/philomena/notifications/creator.ex @@ -1,98 +1,61 @@ defmodule Philomena.Notifications.Creator do @moduledoc """ Internal notifications creation logic. - - Supports two formats for notification creation: - - Key-only (`create_single/4`): The object's id is the only other component inserted. - - Non-key (`create_double/6`): The object's id plus another object's id are inserted. - - See the respective documentation for each function for more details. """ import Ecto.Query, warn: false alias Philomena.Repo @doc """ - Propagate notifications for a notification table type containing a single reference column. - - The single reference column (`name`, `object`) is also part of the unique key for the table, - and is used to select which object to act on. + Propagate notifications for a notification table type. Returns `{:ok, count}`, where `count` is the number of affected rows. - ## Example + ## Examples - iex> create_single( - ...> where(GallerySubscription, gallery_id: ^gallery.id), - ...> GalleryImageNotification, - ...> nil, - ...> :gallery_id, - ...> gallery + iex> broadcast_notification( + ...> from: {GallerySubscription, gallery_id: gallery.id}, + ...> into: GalleryImageNotification, + ...> select: [gallery_id: gallery.id], + ...> unique_key: :gallery_id + ...> ) + {:ok, 2} + + iex> broadcast_notification( + ...> notification_author: user, + ...> from: {ImageSubscription, image_id: image.id}, + ...> into: ImageCommentNotification, + ...> select: [image_id: image.id, comment_id: comment.id], + ...> unique_key: :image_id ...> ) {:ok, 2} """ - def create_single(subscription, notification, user, name, object) do - subscription - |> create_notification_query(user, name, object) - |> create_notification(notification, name) - end + def broadcast_notification(opts) do + opts = Keyword.validate!(opts, [:notification_author, :from, :into, :select, :unique_key]) - @doc """ - Propagate notifications for a notification table type containing two reference columns. + notification_author = Keyword.get(opts, :notification_author, nil) + {subscription_schema, filters} = Keyword.fetch!(opts, :from) + notification_schema = Keyword.fetch!(opts, :into) + select_keywords = Keyword.fetch!(opts, :select) + unique_key = Keyword.fetch!(opts, :unique_key) - The first reference column (`name1`, `object1`) is also part of the unique key for the table, - and is used to select which object to act on. - - Returns `{:ok, count}`, where `count` is the number of affected rows. - - ## Example - - iex> create_double( - ...> where(ImageSubscription, image_id: ^image.id), - ...> ImageCommentNotification, - ...> user, - ...> :image_id, - ...> image, - ...> :comment_id, - ...> comment - ...> ) - {:ok, 2} - - """ - def create_double(subscription, notification, user, name1, object1, name2, object2) do - subscription - |> create_notification_query(user, name1, object1, name2, object2) - |> create_notification(notification, name1) - end - - @doc """ - Clear all unread notifications using the given query. - - Returns `{:ok, count}`, where `count` is the number of affected rows. - """ - def clear(query, user) do - if user do - {count, nil} = - query - |> where(user_id: ^user.id) - |> Repo.delete_all() - - {:ok, count} - else - {:ok, 0} - end + subscription_schema + |> subscription_query(notification_author) + |> where(^filters) + |> convert_to_notification(select_keywords) + |> insert_notifications(notification_schema, unique_key) end # TODO: the following cannot be accomplished with a single query expression # due to this Ecto bug: https://github.com/elixir-ecto/ecto/issues/4430 - defp create_notification_query(subscription, user, name, object) do + defp convert_to_notification(subscription, [{name, object_id}]) do now = DateTime.utc_now(:second) - from s in subscription_query(subscription, user), + from s in subscription, select: %{ - ^name => type(^object.id, :integer), + ^name => type(^object_id, :integer), user_id: s.user_id, created_at: ^now, updated_at: ^now, @@ -100,13 +63,13 @@ defmodule Philomena.Notifications.Creator do } end - defp create_notification_query(subscription, user, name1, object1, name2, object2) do + defp convert_to_notification(subscription, [{name1, object_id1}, {name2, object_id2}]) do now = DateTime.utc_now(:second) - from s in subscription_query(subscription, user), + from s in subscription, select: %{ - ^name1 => type(^object1.id, :integer), - ^name2 => type(^object2.id, :integer), + ^name1 => type(^object_id1, :integer), + ^name2 => type(^object_id2, :integer), user_id: s.user_id, created_at: ^now, updated_at: ^now, @@ -114,8 +77,8 @@ defmodule Philomena.Notifications.Creator do } end - defp subscription_query(subscription, user) do - case user do + defp subscription_query(subscription, notification_author) do + case notification_author do %{id: user_id} -> # Avoid sending notifications to the user which performed the action. from s in subscription, @@ -127,13 +90,13 @@ defmodule Philomena.Notifications.Creator do end end - defp create_notification(query, notification, name) do + defp insert_notifications(query, notification, unique_key) do {count, nil} = Repo.insert_all( notification, query, on_conflict: {:replace_all_except, [:created_at]}, - conflict_target: [name, :user_id] + conflict_target: [unique_key, :user_id] ) {:ok, count} From 183a99bc4f27caaf0f33b7358814c3741235a139 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Jul 2024 14:34:41 -0400 Subject: [PATCH 059/115] Remove background queueing for notification broadcasts --- lib/philomena/comments.ex | 16 +++--------- lib/philomena/galleries.ex | 25 +++++++------------ lib/philomena/images.ex | 12 ++------- lib/philomena/posts.ex | 16 +++--------- lib/philomena/topics.ex | 13 ++-------- lib/philomena/workers/notification_worker.ex | 13 ---------- .../controllers/image/comment_controller.ex | 1 - .../controllers/topic/post_controller.ex | 1 - .../controllers/topic_controller.ex | 1 - 9 files changed, 19 insertions(+), 79 deletions(-) delete mode 100644 lib/philomena/workers/notification_worker.ex diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 4498dca8..e63a288a 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -15,7 +15,6 @@ defmodule Philomena.Comments do alias Philomena.Images.Image alias Philomena.Images alias Philomena.Notifications - alias Philomena.NotificationWorker alias Philomena.Versions alias Philomena.Reports @@ -63,21 +62,13 @@ defmodule Philomena.Comments do |> Multi.one(:image, image_lock_query) |> Multi.insert(:comment, comment) |> Multi.update_all(:update_image, image_query, inc: [comments_count: 1]) + |> Multi.run(:notification, ¬ify_comment/2) |> Images.maybe_subscribe_on(:image, attribution[:user], :watch_on_reply) |> Repo.transaction() end - def notify_comment(comment) do - Exq.enqueue(Exq, "notifications", NotificationWorker, ["Comments", comment.id]) - end - - def perform_notify(comment_id) do - comment = - comment_id - |> get_comment!() - |> Repo.preload([:user, :image]) - - Notifications.create_image_comment_notification(comment.user, comment.image, comment) + defp notify_comment(_repo, %{image: image, comment: comment}) do + Notifications.create_image_comment_notification(comment.user, image, comment) end @doc """ @@ -177,7 +168,6 @@ defmodule Philomena.Comments do |> Repo.transaction() |> case do {:ok, %{comment: comment, reports: {_count, reports}}} -> - notify_comment(comment) UserStatistics.inc_stat(comment.user, :comments_posted) Reports.reindex_reports(reports) reindex_comment(comment) diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index 65516064..d36198b7 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -14,7 +14,6 @@ defmodule Philomena.Galleries do alias Philomena.IndexWorker alias Philomena.GalleryReorderWorker alias Philomena.Notifications - alias Philomena.NotificationWorker alias Philomena.Images use Philomena.Subscriptions, @@ -163,7 +162,7 @@ defmodule Philomena.Galleries do def add_image_to_gallery(gallery, image) do Multi.new() - |> Multi.run(:lock, fn repo, %{} -> + |> Multi.run(:gallery, fn repo, %{} -> gallery = Gallery |> where(id: ^gallery.id) @@ -179,7 +178,7 @@ defmodule Philomena.Galleries do |> Interaction.changeset(%{"image_id" => image.id, "position" => position}) |> repo.insert() end) - |> Multi.run(:gallery, fn repo, %{} -> + |> Multi.run(:image_count, fn repo, %{} -> now = DateTime.utc_now() {count, nil} = @@ -189,11 +188,11 @@ defmodule Philomena.Galleries do {:ok, count} end) + |> Multi.run(:notification, ¬ify_gallery/2) |> Repo.transaction() |> case do {:ok, result} -> Images.reindex_image(image) - notify_gallery(gallery, image) reindex_gallery(gallery) {:ok, result} @@ -205,7 +204,7 @@ defmodule Philomena.Galleries do def remove_image_from_gallery(gallery, image) do Multi.new() - |> Multi.run(:lock, fn repo, %{} -> + |> Multi.run(:gallery, fn repo, %{} -> gallery = Gallery |> where(id: ^gallery.id) @@ -222,7 +221,7 @@ defmodule Philomena.Galleries do {:ok, count} end) - |> Multi.run(:gallery, fn repo, %{interaction: interaction_count} -> + |> Multi.run(:image_count, fn repo, %{interaction: interaction_count} -> now = DateTime.utc_now() {count, nil} = @@ -245,22 +244,16 @@ defmodule Philomena.Galleries do end end + defp notify_gallery(_repo, %{gallery: gallery}) do + Notifications.create_gallery_image_notification(gallery) + end + defp last_position(gallery_id) do Interaction |> where(gallery_id: ^gallery_id) |> Repo.aggregate(:max, :position) end - def notify_gallery(gallery, image) do - Exq.enqueue(Exq, "notifications", NotificationWorker, ["Galleries", [gallery.id, image.id]]) - end - - def perform_notify([gallery_id, _image_id]) do - gallery = get_gallery!(gallery_id) - - Notifications.create_gallery_image_notification(gallery) - end - def reorder_gallery(gallery, image_ids) do Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids]) end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 326ede2d..71da602c 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -24,7 +24,6 @@ defmodule Philomena.Images do alias Philomena.SourceChanges.SourceChange alias Philomena.Notifications.ImageCommentNotification alias Philomena.Notifications.ImageMergeNotification - alias Philomena.NotificationWorker alias Philomena.TagChanges.Limits alias Philomena.TagChanges.TagChange alias Philomena.Tags @@ -602,13 +601,13 @@ defmodule Philomena.Images do |> Multi.run(:migrate_interactions, fn _, %{} -> {:ok, Interactions.migrate_interactions(image, duplicate_of_image)} end) + |> Multi.run(:notification, ¬ify_merge(&1, &2, image, duplicate_of_image)) |> Repo.transaction() |> process_after_hide() |> case do {:ok, result} -> reindex_image(duplicate_of_image) Comments.reindex_comments(duplicate_of_image) - notify_merge(image, duplicate_of_image) {:ok, result} @@ -954,14 +953,7 @@ defmodule Philomena.Images do |> Repo.update() end - def notify_merge(source, target) do - Exq.enqueue(Exq, "notifications", NotificationWorker, ["Images", [source.id, target.id]]) - end - - def perform_notify([source_id, target_id]) do - source = get_image!(source_id) - target = get_image!(target_id) - + defp notify_merge(_repo, _changes, source, target) do Notifications.create_image_merge_notification(target, source) end diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index af29c2e3..ff5a405f 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -16,7 +16,6 @@ defmodule Philomena.Posts do alias Philomena.IndexWorker alias Philomena.Forums.Forum alias Philomena.Notifications - alias Philomena.NotificationWorker alias Philomena.Versions alias Philomena.Reports @@ -93,6 +92,7 @@ defmodule Philomena.Posts do {:ok, count} end) + |> Multi.run(:notification, ¬ify_post/2) |> Topics.maybe_subscribe_on(:topic, attributes[:user], :watch_on_reply) |> Repo.transaction() |> case do @@ -106,8 +106,8 @@ defmodule Philomena.Posts do end end - def notify_post(post) do - Exq.enqueue(Exq, "notifications", NotificationWorker, ["Posts", post.id]) + defp notify_post(_repo, %{post: post, topic: topic}) do + Notifications.create_forum_post_notification(post.user, topic, post) end def report_non_approved(%Post{approved: true}), do: false @@ -120,15 +120,6 @@ defmodule Philomena.Posts do ) end - def perform_notify(post_id) do - post = - post_id - |> get_post!() - |> Repo.preload([:user, :topic]) - - Notifications.create_forum_post_notification(post.user, post.topic, post) - end - @doc """ Updates a post. @@ -241,7 +232,6 @@ defmodule Philomena.Posts do |> Repo.transaction() |> case do {:ok, %{post: post, reports: {_count, reports}}} -> - notify_post(post) UserStatistics.inc_stat(post.user, :forum_posts) Reports.reindex_reports(reports) reindex_post(post) diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index e1e5af29..38a5d602 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -11,7 +11,6 @@ defmodule Philomena.Topics do alias Philomena.Forums.Forum alias Philomena.Posts alias Philomena.Notifications - alias Philomena.NotificationWorker use Philomena.Subscriptions, on_delete: :clear_topic_notification, @@ -73,6 +72,7 @@ defmodule Philomena.Topics do {:ok, count} end) + |> Multi.run(:notification, ¬ify_topic/2) |> maybe_subscribe_on(:topic, attribution[:user], :watch_on_new_topic) |> Repo.transaction() |> case do @@ -87,16 +87,7 @@ defmodule Philomena.Topics do end end - def notify_topic(topic, post) do - Exq.enqueue(Exq, "notifications", NotificationWorker, ["Topics", [topic.id, post.id]]) - end - - def perform_notify([topic_id, _post_id]) do - topic = - topic_id - |> get_topic!() - |> Repo.preload(:user) - + defp notify_topic(_repo, %{topic: topic}) do Notifications.create_forum_topic_notification(topic.user, topic) end diff --git a/lib/philomena/workers/notification_worker.ex b/lib/philomena/workers/notification_worker.ex deleted file mode 100644 index 8bec8e61..00000000 --- a/lib/philomena/workers/notification_worker.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Philomena.NotificationWorker do - @modules %{ - "Comments" => Philomena.Comments, - "Galleries" => Philomena.Galleries, - "Images" => Philomena.Images, - "Posts" => Philomena.Posts, - "Topics" => Philomena.Topics - } - - def perform(module, args) do - @modules[module].perform_notify(args) - end -end diff --git a/lib/philomena_web/controllers/image/comment_controller.ex b/lib/philomena_web/controllers/image/comment_controller.ex index ca0ff9ad..86fb712d 100644 --- a/lib/philomena_web/controllers/image/comment_controller.ex +++ b/lib/philomena_web/controllers/image/comment_controller.ex @@ -82,7 +82,6 @@ defmodule PhilomenaWeb.Image.CommentController do Images.reindex_image(conn.assigns.image) if comment.approved do - Comments.notify_comment(comment) UserStatistics.inc_stat(conn.assigns.current_user, :comments_posted) else Comments.report_non_approved(comment) diff --git a/lib/philomena_web/controllers/topic/post_controller.ex b/lib/philomena_web/controllers/topic/post_controller.ex index 8f90abd0..a87c73c9 100644 --- a/lib/philomena_web/controllers/topic/post_controller.ex +++ b/lib/philomena_web/controllers/topic/post_controller.ex @@ -36,7 +36,6 @@ defmodule PhilomenaWeb.Topic.PostController do case Posts.create_post(topic, attributes, post_params) do {:ok, %{post: post}} -> if post.approved do - Posts.notify_post(post) UserStatistics.inc_stat(conn.assigns.current_user, :forum_posts) else Posts.report_non_approved(post) diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex index f68fbcda..4d3099e8 100644 --- a/lib/philomena_web/controllers/topic_controller.ex +++ b/lib/philomena_web/controllers/topic_controller.ex @@ -111,7 +111,6 @@ defmodule PhilomenaWeb.TopicController do case Topics.create_topic(forum, attributes, topic_params) do {:ok, %{topic: topic}} -> post = hd(topic.posts) - Topics.notify_topic(topic, post) if forum.access_level == "normal" do PhilomenaWeb.Endpoint.broadcast!( From 8d779ec1f9db1d661cb545f0d14e3ebb557070be Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jul 2024 14:45:30 -0400 Subject: [PATCH 060/115] Remove referrer, user_agent, and other unused fields --- index/posts.mk | 4 +- lib/philomena/artist_links/artist_link.ex | 13 ----- lib/philomena/channels/channel.ex | 11 ---- lib/philomena/comments/comment.ex | 7 --- lib/philomena/images.ex | 9 --- lib/philomena/images/image.ex | 57 ------------------- lib/philomena/polls/poll.ex | 4 -- lib/philomena/posts/post.ex | 8 --- lib/philomena/posts/search_index.ex | 4 +- lib/philomena/reports.ex | 4 +- lib/philomena/reports/report.ex | 4 +- lib/philomena/roles/role.ex | 5 -- lib/philomena/source_changes/source_change.ex | 2 - lib/philomena/tag_changes/tag_change.ex | 2 - lib/philomena/users/eraser.ex | 4 +- lib/philomena/users/user.ex | 1 - .../controllers/admin/batch/tag_controller.ex | 2 - .../tag_change/full_revert_controller.ex | 2 - .../tag_change/revert_controller.ex | 2 - .../plugs/user_attribution_plug.ex | 7 +-- .../templates/report/new.html.slime | 1 + .../templates/topic/poll/_display.html.slime | 26 ++------- lib/philomena_web/views/report_view.ex | 7 +++ lib/philomena_web/views/topic/poll_view.ex | 2 +- priv/repo/seeds_development.exs | 2 - 25 files changed, 24 insertions(+), 166 deletions(-) diff --git a/index/posts.mk b/index/posts.mk index 4d530713..8324d633 100644 --- a/index/posts.mk +++ b/index/posts.mk @@ -21,8 +21,8 @@ metadata: post_search_json 'body', p.body, 'subject', t.title, 'ip', p.ip, - 'user_agent', p.user_agent, - 'referrer', p.referrer, + 'user_agent', '', + 'referrer', '', 'fingerprint', p.fingerprint, 'topic_position', p.topic_position, 'forum', f.short_name, diff --git a/lib/philomena/artist_links/artist_link.ex b/lib/philomena/artist_links/artist_link.ex index f79a872b..a3bcce8d 100644 --- a/lib/philomena/artist_links/artist_link.ex +++ b/lib/philomena/artist_links/artist_link.ex @@ -13,8 +13,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do field :aasm_state, :string, default: "unverified" field :uri, :string - field :hostname, :string - field :path, :string field :verification_code, :string field :public, :boolean, default: true field :next_check_at, :utc_datetime @@ -35,7 +33,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do |> cast(attrs, [:uri, :public]) |> put_change(:tag_id, nil) |> validate_required([:user, :uri, :public]) - |> parse_uri() end def edit_changeset(artist_link, attrs, tag) do @@ -43,7 +40,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do |> cast(attrs, [:uri, :public]) |> put_change(:tag_id, tag.id) |> validate_required([:user, :uri, :public]) - |> parse_uri() end def creation_changeset(artist_link, attrs, user, tag) do @@ -55,7 +51,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do |> validate_required([:tag], message: "must exist") |> validate_format(:uri, ~r|\Ahttps?://|) |> validate_category() - |> parse_uri() |> put_verification_code() |> put_next_check_at() |> unique_constraint([:uri, :tag_id, :user_id], @@ -95,14 +90,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do |> put_change(:aasm_state, "contacted") end - defp parse_uri(changeset) do - string_uri = get_field(changeset, :uri) |> to_string() - uri = URI.parse(string_uri) - - changeset - |> change(hostname: uri.host, path: uri.path) - end - defp put_verification_code(changeset) do code = :crypto.strong_rand_bytes(5) |> Base.encode16() change(changeset, verification_code: "DERPI-LINKVALIDATION-#{code}") diff --git a/lib/philomena/channels/channel.ex b/lib/philomena/channels/channel.ex index 54bf30ba..58d2942c 100644 --- a/lib/philomena/channels/channel.ex +++ b/lib/philomena/channels/channel.ex @@ -12,7 +12,6 @@ defmodule Philomena.Channels.Channel do field :short_name, :string field :title, :string, default: "" - field :tags, :string field :viewers, :integer, default: 0 field :nsfw, :boolean, default: false field :is_live, :boolean, default: false @@ -20,16 +19,6 @@ defmodule Philomena.Channels.Channel do field :next_check_at, :utc_datetime field :last_live_at, :utc_datetime - field :viewer_minutes_today, :integer, default: 0 - field :viewer_minutes_thisweek, :integer, default: 0 - field :viewer_minutes_thismonth, :integer, default: 0 - field :total_viewer_minutes, :integer, default: 0 - - field :banner_image, :string - field :channel_image, :string - field :remote_stream_id, :integer - field :thumbnail_url, :string, default: "" - timestamps(inserted_at: :created_at, type: :utc_datetime) end diff --git a/lib/philomena/comments/comment.ex b/lib/philomena/comments/comment.ex index 9571b450..e54a415b 100644 --- a/lib/philomena/comments/comment.ex +++ b/lib/philomena/comments/comment.ex @@ -14,15 +14,12 @@ defmodule Philomena.Comments.Comment do field :body, :string field :ip, EctoNetwork.INET field :fingerprint, :string - field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :anonymous, :boolean, default: false field :hidden_from_users, :boolean, default: false field :edit_reason, :string field :edited_at, :utc_datetime field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false - field :name_at_post_time, :string field :approved, :boolean timestamps(inserted_at: :created_at, type: :utc_datetime) @@ -35,7 +32,6 @@ defmodule Philomena.Comments.Comment do |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> change(attribution) - |> put_name_at_post_time(attribution[:user]) |> Approval.maybe_put_approval(attribution[:user]) |> Approval.maybe_strip_images(attribution[:user]) end @@ -74,7 +70,4 @@ defmodule Philomena.Comments.Comment do change(comment) |> put_change(:approved, true) end - - defp put_name_at_post_time(changeset, nil), do: changeset - defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name) end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 3aefa4c2..802f36aa 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -94,11 +94,6 @@ defmodule Philomena.Images do Multi.new() |> Multi.insert(:image, image) - |> Multi.run(:name_caches, fn repo, %{image: image} -> - image - |> Image.cache_changeset() - |> repo.update() - end) |> Multi.run(:added_tag_count, fn repo, %{image: image} -> tag_ids = image.added_tags |> Enum.map(& &1.id) tags = Tag |> where([t], t.id in ^tag_ids) @@ -384,8 +379,6 @@ defmodule Philomena.Images do updated_at: now, ip: attribution[:ip], fingerprint: attribution[:fingerprint], - user_agent: attribution[:user_agent], - referrer: attribution[:referrer], added: added } end @@ -521,8 +514,6 @@ defmodule Philomena.Images do tag_name_cache: tag.name, ip: attribution[:ip], fingerprint: attribution[:fingerprint], - user_agent: attribution[:user_agent], - referrer: attribution[:referrer], added: added } end diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index ee03b651..e02356dd 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -2,7 +2,6 @@ defmodule Philomena.Images.Image do use Ecto.Schema import Ecto.Changeset - import Ecto.Query alias Philomena.ImageIntensities.ImageIntensity alias Philomena.ImageVotes.ImageVote @@ -59,14 +58,11 @@ defmodule Philomena.Images.Image do field :image_is_animated, :boolean, source: :is_animated field :ip, EctoNetwork.INET field :fingerprint, :string - field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :anonymous, :boolean, default: false field :score, :integer, default: 0 field :faves_count, :integer, default: 0 field :upvotes_count, :integer, default: 0 field :downvotes_count, :integer, default: 0 - field :votes_count, :integer, default: 0 field :source_url, :string field :description, :string, default: "" field :image_sha512_hash, :string @@ -88,11 +84,6 @@ defmodule Philomena.Images.Image do field :hides_count, :integer, default: 0 field :approved, :boolean - # todo: can probably remove these now - field :tag_list_cache, :string - field :tag_list_plus_alias_cache, :string - field :file_name_cache, :string - field :removed_tags, {:array, :any}, default: [], virtual: true field :added_tags, {:array, :any}, default: [], virtual: true field :removed_sources, {:array, :any}, default: [], virtual: true @@ -228,7 +219,6 @@ defmodule Philomena.Images.Image do |> cast(attrs, []) |> TagDiffer.diff_input(old_tags, new_tags, excluded_tags) |> TagValidator.validate_tags() - |> cache_changeset() end def locked_tags_changeset(image, attrs, locked_tags) do @@ -345,53 +335,6 @@ defmodule Philomena.Images.Image do |> put_change(:first_seen_at, DateTime.utc_now(:second)) end - def cache_changeset(image) do - changeset = change(image) - image = apply_changes(changeset) - - {tag_list_cache, tag_list_plus_alias_cache, file_name_cache} = - create_caches(image.id, image.tags) - - changeset - |> put_change(:tag_list_cache, tag_list_cache) - |> put_change(:tag_list_plus_alias_cache, tag_list_plus_alias_cache) - |> put_change(:file_name_cache, file_name_cache) - end - - defp create_caches(image_id, tags) do - tags = Tag.display_order(tags) - - tag_list_cache = - tags - |> Enum.map_join(", ", & &1.name) - - tag_ids = tags |> Enum.map(& &1.id) - - aliases = - Tag - |> where([t], t.aliased_tag_id in ^tag_ids) - |> Repo.all() - - tag_list_plus_alias_cache = - (tags ++ aliases) - |> Tag.display_order() - |> Enum.map_join(", ", & &1.name) - - # Truncate filename to 150 characters, making room for the path + filename on Windows - # https://stackoverflow.com/questions/265769/maximum-filename-length-in-ntfs-windows-xp-and-windows-vista - file_name_slug_fragment = - tags - |> Enum.map_join("_", & &1.slug) - |> String.to_charlist() - |> Enum.filter(&(&1 in ?a..?z or &1 in ~c"0123456789_-")) - |> List.to_string() - |> String.slice(0..150) - - file_name_cache = "#{image_id}__#{file_name_slug_fragment}" - - {tag_list_cache, tag_list_plus_alias_cache, file_name_cache} - end - defp create_key do Base.encode16(:crypto.strong_rand_bytes(6), case: :lower) end diff --git a/lib/philomena/polls/poll.ex b/lib/philomena/polls/poll.ex index b9032e7e..eb998265 100644 --- a/lib/philomena/polls/poll.ex +++ b/lib/philomena/polls/poll.ex @@ -3,20 +3,16 @@ defmodule Philomena.Polls.Poll do import Ecto.Changeset alias Philomena.Topics.Topic - alias Philomena.Users.User alias Philomena.PollOptions.PollOption schema "polls" do belongs_to :topic, Topic - belongs_to :deleted_by, User has_many :options, PollOption field :title, :string field :vote_method, :string field :active_until, PhilomenaQuery.Ecto.RelativeDate field :total_votes, :integer, default: 0 - field :hidden_from_users, :boolean, default: false - field :deletion_reason, :string, default: "" timestamps(inserted_at: :created_at, type: :utc_datetime) end diff --git a/lib/philomena/posts/post.ex b/lib/philomena/posts/post.ex index 55d5b401..11fc87bf 100644 --- a/lib/philomena/posts/post.ex +++ b/lib/philomena/posts/post.ex @@ -15,15 +15,12 @@ defmodule Philomena.Posts.Post do field :edit_reason, :string field :ip, EctoNetwork.INET field :fingerprint, :string - field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :topic_position, :integer field :hidden_from_users, :boolean, default: false field :anonymous, :boolean, default: false field :edited_at, :utc_datetime field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false - field :name_at_post_time, :string field :approved, :boolean, default: false timestamps(inserted_at: :created_at, type: :utc_datetime) @@ -47,7 +44,6 @@ defmodule Philomena.Posts.Post do |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> change(attribution) - |> put_name_at_post_time(attribution[:user]) |> Approval.maybe_put_approval(attribution[:user]) |> Approval.maybe_strip_images(attribution[:user]) end @@ -61,7 +57,6 @@ defmodule Philomena.Posts.Post do |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> change(attribution) |> change(topic_position: 0) - |> put_name_at_post_time(attribution[:user]) |> Approval.maybe_put_approval(attribution[:user]) |> Approval.maybe_strip_images(attribution[:user]) end @@ -90,7 +85,4 @@ defmodule Philomena.Posts.Post do change(post) |> put_change(:approved, true) end - - defp put_name_at_post_time(changeset, nil), do: changeset - defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name) end diff --git a/lib/philomena/posts/search_index.ex b/lib/philomena/posts/search_index.ex index 9c8c2780..b5522fa1 100644 --- a/lib/philomena/posts/search_index.ex +++ b/lib/philomena/posts/search_index.ex @@ -52,8 +52,8 @@ defmodule Philomena.Posts.SearchIndex do author: if(!!post.user and !post.anonymous, do: String.downcase(post.user.name)), subject: post.topic.title, ip: post.ip |> to_string(), - user_agent: post.user_agent, - referrer: post.referrer, + user_agent: "", + referrer: "", fingerprint: post.fingerprint, topic_position: post.topic_position, forum: post.topic.forum.short_name, diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 5cf508dd..7e0466dc 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -139,9 +139,7 @@ defmodule Philomena.Reports do attributes = %{ system: true, ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, - fingerprint: "ffff", - user_agent: - "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0" + fingerprint: "ffff" } %Report{reportable_type: reportable_type, reportable_id: reportable_id} diff --git a/lib/philomena/reports/report.ex b/lib/philomena/reports/report.ex index 461b6eea..a17b5a34 100644 --- a/lib/philomena/reports/report.ex +++ b/lib/philomena/reports/report.ex @@ -11,7 +11,6 @@ defmodule Philomena.Reports.Report do field :ip, EctoNetwork.INET field :fingerprint, :string field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :reason, :string field :state, :string, default: "open" field :open, :boolean, default: true @@ -61,8 +60,9 @@ defmodule Philomena.Reports.Report do @doc false def creation_changeset(report, attrs, attribution) do report - |> cast(attrs, [:category, :reason]) + |> cast(attrs, [:category, :reason, :user_agent]) |> validate_length(:reason, max: 10_000, count: :bytes) + |> validate_length(:user_agent, max: 1000, count: :bytes) |> merge_category() |> change(attribution) |> validate_required([ diff --git a/lib/philomena/roles/role.ex b/lib/philomena/roles/role.ex index 359c90b1..27ccb73a 100644 --- a/lib/philomena/roles/role.ex +++ b/lib/philomena/roles/role.ex @@ -4,12 +4,7 @@ defmodule Philomena.Roles.Role do schema "roles" do field :name, :string - - # fixme: rails polymorphic relation - field :resource_id, :integer field :resource_type, :string - - timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false diff --git a/lib/philomena/source_changes/source_change.ex b/lib/philomena/source_changes/source_change.ex index 3cff4685..d17e0d93 100644 --- a/lib/philomena/source_changes/source_change.ex +++ b/lib/philomena/source_changes/source_change.ex @@ -8,8 +8,6 @@ defmodule Philomena.SourceChanges.SourceChange do field :ip, EctoNetwork.INET field :fingerprint, :string - field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :value, :string field :added, :boolean diff --git a/lib/philomena/tag_changes/tag_change.ex b/lib/philomena/tag_changes/tag_change.ex index 3bc9eb10..6d33397d 100644 --- a/lib/philomena/tag_changes/tag_change.ex +++ b/lib/philomena/tag_changes/tag_change.ex @@ -9,8 +9,6 @@ defmodule Philomena.TagChanges.TagChange do field :ip, EctoNetwork.INET field :fingerprint, :string - field :user_agent, :string, default: "" - field :referrer, :string, default: "" field :added, :boolean field :tag_name_cache, :string, default: "" diff --git a/lib/philomena/users/eraser.ex b/lib/philomena/users/eraser.ex index d584ac77..cde0745f 100644 --- a/lib/philomena/users/eraser.ex +++ b/lib/philomena/users/eraser.ex @@ -115,9 +115,7 @@ defmodule Philomena.Users.Eraser do attribution = [ user: user, ip: @wipe_ip, - fingerprint: @wipe_fp, - user_agent: "", - referrer: "" + fingerprint: @wipe_fp ] {:ok, _} = Images.update_sources(source_change.image, attribution, attrs) diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 6b300a7c..1b32723a 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -109,7 +109,6 @@ defmodule Philomena.Users.User do field :watched_tag_list, :string, virtual: true # Other stuff - field :last_donation_at, :utc_datetime field :last_renamed_at, :utc_datetime field :deleted_at, :utc_datetime field :scratchpad, :string diff --git a/lib/philomena_web/controllers/admin/batch/tag_controller.ex b/lib/philomena_web/controllers/admin/batch/tag_controller.ex index 44e6485b..835917a0 100644 --- a/lib/philomena_web/controllers/admin/batch/tag_controller.ex +++ b/lib/philomena_web/controllers/admin/batch/tag_controller.ex @@ -37,8 +37,6 @@ defmodule PhilomenaWeb.Admin.Batch.TagController do attributes = %{ ip: attributes[:ip], fingerprint: attributes[:fingerprint], - user_agent: attributes[:user_agent], - referrer: attributes[:referrer], user_id: attributes[:user].id } diff --git a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex b/lib/philomena_web/controllers/tag_change/full_revert_controller.ex index 3ebfd471..2f095715 100644 --- a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex +++ b/lib/philomena_web/controllers/tag_change/full_revert_controller.ex @@ -11,8 +11,6 @@ defmodule PhilomenaWeb.TagChange.FullRevertController do attributes = %{ ip: to_string(attributes[:ip]), fingerprint: attributes[:fingerprint], - referrer: attributes[:referrer], - user_agent: attributes[:referrer], user_id: attributes[:user].id, batch_size: attributes[:batch_size] || 100 } diff --git a/lib/philomena_web/controllers/tag_change/revert_controller.ex b/lib/philomena_web/controllers/tag_change/revert_controller.ex index fba63fee..84782304 100644 --- a/lib/philomena_web/controllers/tag_change/revert_controller.ex +++ b/lib/philomena_web/controllers/tag_change/revert_controller.ex @@ -13,8 +13,6 @@ defmodule PhilomenaWeb.TagChange.RevertController do attributes = %{ ip: attributes[:ip], fingerprint: attributes[:fingerprint], - referrer: attributes[:referrer], - user_agent: attributes[:referrer], user_id: attributes[:user].id } diff --git a/lib/philomena_web/plugs/user_attribution_plug.ex b/lib/philomena_web/plugs/user_attribution_plug.ex index bfbd5da8..88bcb75d 100644 --- a/lib/philomena_web/plugs/user_attribution_plug.ex +++ b/lib/philomena_web/plugs/user_attribution_plug.ex @@ -24,9 +24,7 @@ defmodule PhilomenaWeb.UserAttributionPlug do attributes = [ ip: remote_ip, fingerprint: fingerprint(conn, conn.path_info), - referrer: referrer(conn.assigns.referrer), - user: user, - user_agent: user_agent(conn) + user: user ] conn @@ -47,7 +45,4 @@ defmodule PhilomenaWeb.UserAttributionPlug do defp fingerprint(conn, _) do conn.cookies["_ses"] end - - defp referrer(nil), do: nil - defp referrer(r), do: String.slice(r, 0, 255) end diff --git a/lib/philomena_web/templates/report/new.html.slime b/lib/philomena_web/templates/report/new.html.slime index f7839783..bdc820cd 100644 --- a/lib/philomena_web/templates/report/new.html.slime +++ b/lib/philomena_web/templates/report/new.html.slime @@ -47,6 +47,7 @@ p .block = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, placeholder: "Provide anything else we should know here.", name: :reason, required: false + = hidden_input f, :user_agent, value: get_user_agent(@conn) = render PhilomenaWeb.CaptchaView, "_captcha.html", name: "report", conn: @conn = submit "Send Report", class: "button" diff --git a/lib/philomena_web/templates/topic/poll/_display.html.slime b/lib/philomena_web/templates/topic/poll/_display.html.slime index 780b6336..12c7999d 100644 --- a/lib/philomena_web/templates/topic/poll/_display.html.slime +++ b/lib/philomena_web/templates/topic/poll/_display.html.slime @@ -6,26 +6,12 @@ = link "Administrate", to: "#", data: [click_tab: "administration"] .block__tab data-tab="voting" - = cond do - - @poll.hidden_from_users -> - .walloftext - .block.block--fixed.block--warning - h1 This poll has been deleted - p - ' Reason: - strong - = @poll.deletion_reason || "Unknown (likely deleted in error). Please contact a moderator." - - - @poll_active and not @voted and not is_nil(@conn.assigns.current_user) -> - .poll - .poll-area - = render PhilomenaWeb.Topic.PollView, "_vote_form.html", assigns - - - true -> - .poll - .poll-area - = render PhilomenaWeb.Topic.PollView, "_results.html", assigns - + .poll + .poll-area + = if @poll_active and not @voted and not is_nil(@conn.assigns.current_user) do + = render PhilomenaWeb.Topic.PollView, "_vote_form.html", assigns + - else + = render PhilomenaWeb.Topic.PollView, "_results.html", assigns = if can?(@conn, :hide, @topic) do .block__tab.hidden data-tab="voters" diff --git a/lib/philomena_web/views/report_view.ex b/lib/philomena_web/views/report_view.ex index 35693434..0321d6cb 100644 --- a/lib/philomena_web/views/report_view.ex +++ b/lib/philomena_web/views/report_view.ex @@ -79,4 +79,11 @@ defmodule PhilomenaWeb.ReportView do def link_to_reported_thing(_reportable) do "Reported item permanently destroyed." end + + def get_user_agent(conn) do + case Plug.Conn.get_req_header(conn, "user-agent") do + [ua] -> ua + _ -> "" + end + end end diff --git a/lib/philomena_web/views/topic/poll_view.ex b/lib/philomena_web/views/topic/poll_view.ex index b8c88c87..e801b43f 100644 --- a/lib/philomena_web/views/topic/poll_view.ex +++ b/lib/philomena_web/views/topic/poll_view.ex @@ -13,7 +13,7 @@ defmodule PhilomenaWeb.Topic.PollView do end def active?(poll) do - not poll.hidden_from_users and DateTime.diff(poll.active_until, DateTime.utc_now()) > 0 + DateTime.diff(poll.active_until, DateTime.utc_now()) > 0 end def require_answer?(%{vote_method: vote_method}), do: vote_method == "single" diff --git a/priv/repo/seeds_development.exs b/priv/repo/seeds_development.exs index cde0302a..47bcd1e7 100644 --- a/priv/repo/seeds_development.exs +++ b/priv/repo/seeds_development.exs @@ -40,8 +40,6 @@ pleb = Repo.get_by!(User, name: "Pleb") request_attributes = [ fingerprint: "c1836832948", ip: ip, - user_agent: "Hopefully not IE", - referrer: "localhost", user_id: pleb.id, user: pleb ] From 6471718a3c9cb3fbd9ceadc7077f5cedcac86bbe Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 5 Aug 2024 08:22:11 -0400 Subject: [PATCH 061/115] Fixup --- lib/philomena/channels/channel.ex | 1 + lib/philomena_web/views/channel_view.ex | 24 ++---------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/lib/philomena/channels/channel.ex b/lib/philomena/channels/channel.ex index 58d2942c..7f70351a 100644 --- a/lib/philomena/channels/channel.ex +++ b/lib/philomena/channels/channel.ex @@ -18,6 +18,7 @@ defmodule Philomena.Channels.Channel do field :last_fetched_at, :utc_datetime field :next_check_at, :utc_datetime field :last_live_at, :utc_datetime + field :thumbnail_url, :string, default: "" timestamps(inserted_at: :created_at, type: :utc_datetime) end diff --git a/lib/philomena_web/views/channel_view.ex b/lib/philomena_web/views/channel_view.ex index acb4880a..c8f1927c 100644 --- a/lib/philomena_web/views/channel_view.ex +++ b/lib/philomena_web/views/channel_view.ex @@ -1,27 +1,7 @@ defmodule PhilomenaWeb.ChannelView do use PhilomenaWeb, :view - def channel_image(%{type: "LivestreamChannel", short_name: short_name}) do - now = DateTime.utc_now() |> DateTime.to_unix(:microsecond) - - PhilomenaProxy.Camo.image_url( - "https://thumbnail.api.livestream.com/thumbnail?name=#{short_name}&rand=#{now}" - ) + def channel_image(%{thumbnail_url: thumbnail_url}) do + PhilomenaProxy.Camo.image_url(thumbnail_url || "https://picarto.tv/images/missingthumb.jpg") end - - def channel_image(%{type: "PicartoChannel", thumbnail_url: thumbnail_url}), - do: - PhilomenaProxy.Camo.image_url(thumbnail_url || "https://picarto.tv/images/missingthumb.jpg") - - def channel_image(%{type: "PiczelChannel", remote_stream_id: remote_stream_id}), - do: - PhilomenaProxy.Camo.image_url( - "https://piczel.tv/api/thumbnail/stream_#{remote_stream_id}.jpg" - ) - - def channel_image(%{type: "TwitchChannel", short_name: short_name}), - do: - PhilomenaProxy.Camo.image_url( - "https://static-cdn.jtvnw.net/previews-ttv/live_user_#{String.downcase(short_name)}-320x180.jpg" - ) end From e704319dd94d50ce7ed17a8e1994ee26bea93f1a Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 6 Aug 2024 08:32:41 -0400 Subject: [PATCH 062/115] Deduplicate select expressions in convert_to_notification --- lib/philomena/notifications/creator.ex | 38 +++++++++----------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex index 1ceec14f..808a6d3d 100644 --- a/lib/philomena/notifications/creator.ex +++ b/lib/philomena/notifications/creator.ex @@ -47,34 +47,22 @@ defmodule Philomena.Notifications.Creator do |> insert_notifications(notification_schema, unique_key) end - # TODO: the following cannot be accomplished with a single query expression - # due to this Ecto bug: https://github.com/elixir-ecto/ecto/issues/4430 + defp convert_to_notification(subscription, extra) do + now = dynamic([_], type(^DateTime.utc_now(:second), :utc_datetime)) - defp convert_to_notification(subscription, [{name, object_id}]) do - now = DateTime.utc_now(:second) + base = %{ + user_id: dynamic([s], s.user_id), + created_at: now, + updated_at: now, + read: false + } - from s in subscription, - select: %{ - ^name => type(^object_id, :integer), - user_id: s.user_id, - created_at: ^now, - updated_at: ^now, - read: false - } - end + extra = + Map.new(extra, fn {field, value} -> + {field, dynamic([_], type(^value, :integer))} + end) - defp convert_to_notification(subscription, [{name1, object_id1}, {name2, object_id2}]) do - now = DateTime.utc_now(:second) - - from s in subscription, - select: %{ - ^name1 => type(^object_id1, :integer), - ^name2 => type(^object_id2, :integer), - user_id: s.user_id, - created_at: ^now, - updated_at: ^now, - read: false - } + from(subscription, select: ^Map.merge(base, extra)) end defp subscription_query(subscription, notification_author) do From 2a89162cba104f9c71c946ce902195a213cd02a6 Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 8 Aug 2024 08:24:43 -0400 Subject: [PATCH 063/115] Fixup polls --- lib/philomena_web/plugs/load_poll_plug.ex | 26 ++++++----------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/lib/philomena_web/plugs/load_poll_plug.ex b/lib/philomena_web/plugs/load_poll_plug.ex index 5bbfd257..6a8bec7f 100644 --- a/lib/philomena_web/plugs/load_poll_plug.ex +++ b/lib/philomena_web/plugs/load_poll_plug.ex @@ -2,32 +2,20 @@ defmodule PhilomenaWeb.LoadPollPlug do alias Philomena.Polls.Poll alias Philomena.Repo - import Plug.Conn, only: [assign: 3] - import Canada.Can, only: [can?: 3] import Ecto.Query - def init(opts), - do: opts - - def call(%{assigns: %{topic: topic}} = conn, opts) do - show_hidden = Keyword.get(opts, :show_hidden, false) + def init(opts), do: opts + def call(%{assigns: %{topic: topic}} = conn, _opts) do Poll |> where(topic_id: ^topic.id) |> Repo.one() - |> maybe_hide_poll(conn, show_hidden) - end + |> case do + nil -> + PhilomenaWeb.NotFoundPlug.call(conn) - defp maybe_hide_poll(nil, conn, _show_hidden), - do: PhilomenaWeb.NotFoundPlug.call(conn) - - defp maybe_hide_poll(%{hidden_from_users: false} = poll, conn, _show_hidden), - do: assign(conn, :poll, poll) - - defp maybe_hide_poll(poll, %{assigns: %{current_user: user}} = conn, show_hidden) do - case show_hidden or can?(user, :show, poll) do - true -> assign(conn, :poll, poll) - false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + poll -> + Plug.Conn.assign(conn, :poll, poll) end end end From 0122efcbc8f4bd52de07514892ab763909609c33 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 9 Aug 2024 00:22:56 +0400 Subject: [PATCH 064/115] Fixed asserting throwing an error when user is not logged in This selector is optional and does not exist for the not-logged-in users. We don't really need to assert it existence in this case. --- assets/js/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/settings.ts b/assets/js/settings.ts index c60a2ee0..a294c48a 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -10,7 +10,7 @@ export function setupSettings() { if (!$('#js-setting-table')) return; const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]'); - const themeSelect = assertNotNull($('#user_theme')); + const themeSelect = $('#user_theme'); const styleSheet = assertNotNull($('head link[rel="stylesheet"]')); // Local settings From 19a14b7e63cca2dd11628545c884cc8ac20e884c Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 13 Aug 2024 21:12:08 -0400 Subject: [PATCH 065/115] Dependency updates --- assets/package-lock.json | 805 ++++++++++++++++++++------------------- docker-compose.yml | 8 +- docker/app/Dockerfile | 2 +- mix.lock | 62 ++- 4 files changed, 440 insertions(+), 437 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index 9f53e1b8..acd3d7fc 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -64,9 +64,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -159,10 +159,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", "dev": true, + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -171,9 +174,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", - "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -183,12 +186,12 @@ } }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, @@ -203,9 +206,9 @@ "dev": true }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -218,9 +221,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -233,9 +236,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -248,9 +251,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -263,9 +266,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -278,9 +281,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -293,9 +296,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -308,9 +311,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -323,9 +326,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -338,9 +341,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -353,9 +356,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -368,9 +371,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -383,9 +386,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -398,9 +401,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -413,9 +416,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -428,9 +431,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -443,9 +446,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -458,9 +461,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -473,9 +476,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -488,9 +491,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -503,9 +506,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -518,9 +521,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -533,9 +536,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -573,21 +576,21 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", - "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.15.1.tgz", - "integrity": "sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", "dependencies": { - "@eslint/object-schema": "^2.1.3", + "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", - "minimatch": "^3.0.5" + "minimatch": "^3.1.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -616,9 +619,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.4.0.tgz", - "integrity": "sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -632,10 +635,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz", - "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz", + "integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==", "engines": { "node": ">=6" } @@ -763,9 +765,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -823,9 +825,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", - "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", + "integrity": "sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==", "cpu": [ "arm" ], @@ -835,9 +837,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", - "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.20.0.tgz", + "integrity": "sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==", "cpu": [ "arm64" ], @@ -847,9 +849,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", - "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.20.0.tgz", + "integrity": "sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==", "cpu": [ "arm64" ], @@ -859,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", - "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.20.0.tgz", + "integrity": "sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==", "cpu": [ "x64" ], @@ -871,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", - "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.20.0.tgz", + "integrity": "sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==", "cpu": [ "arm" ], @@ -883,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", - "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.20.0.tgz", + "integrity": "sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==", "cpu": [ "arm" ], @@ -895,9 +897,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", - "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.20.0.tgz", + "integrity": "sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==", "cpu": [ "arm64" ], @@ -907,9 +909,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", - "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.20.0.tgz", + "integrity": "sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==", "cpu": [ "arm64" ], @@ -919,9 +921,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", - "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.20.0.tgz", + "integrity": "sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==", "cpu": [ "ppc64" ], @@ -931,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", - "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.20.0.tgz", + "integrity": "sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==", "cpu": [ "riscv64" ], @@ -943,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", - "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.20.0.tgz", + "integrity": "sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==", "cpu": [ "s390x" ], @@ -955,9 +957,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", - "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.20.0.tgz", + "integrity": "sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==", "cpu": [ "x64" ], @@ -967,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", - "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.20.0.tgz", + "integrity": "sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==", "cpu": [ "x64" ], @@ -979,9 +981,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", - "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.20.0.tgz", + "integrity": "sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==", "cpu": [ "arm64" ], @@ -991,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", - "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.20.0.tgz", + "integrity": "sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==", "cpu": [ "ia32" ], @@ -1003,9 +1005,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", - "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.20.0.tgz", + "integrity": "sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==", "cpu": [ "x64" ], @@ -1036,9 +1038,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", - "integrity": "sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -1055,9 +1057,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.6.tgz", - "integrity": "sha512-8qpnGVincVDLEcQXWaHOf6zmlbwTKc6Us6PPu4CRnPXCzo2OGBS5cwgMMOWdxDpEz1mkbvXHpEy99M5Yvt682w==", + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", + "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", "dev": true, "dependencies": { "@adobe/css-tools": "^4.4.0", @@ -1073,30 +1075,6 @@ "node": ">=14", "npm": ">=6", "yarn": ">=1" - }, - "peerDependencies": { - "@jest/globals": ">= 28", - "@types/bun": "latest", - "@types/jest": ">= 28", - "jest": ">= 28", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@types/bun": { - "optional": true - }, - "@types/jest": { - "optional": true - }, - "jest": { - "optional": true - }, - "vitest": { - "optional": true - } } }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { @@ -1133,9 +1111,9 @@ "dev": true }, "node_modules/@types/chai": { - "version": "4.3.16", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.16.tgz", - "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", + "version": "4.3.17", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.17.tgz", + "integrity": "sha512-zmZ21EWzR71B4Sscphjief5djsLre50M6lI622OSySTmn9DB3j+C3kWroHfBQWXbOBwbgg/M8CG/hUxDLIloow==", "dev": true }, "node_modules/@types/chai-dom": { @@ -1184,11 +1162,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", - "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", + "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.13.0" } }, "node_modules/@types/stack-utils": { @@ -1207,9 +1185,9 @@ "integrity": "sha512-HX2eARbn26tZuCOxZ25Ew6UUNhw8fgdGrOGcxX0/J6yTtlJm+nHlL9/h+2zgSzse13vlVe+c+W3LWqhnlAd5rg==" }, "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dependencies": { "@types/yargs-parser": "*" } @@ -1477,9 +1455,9 @@ } }, "node_modules/@vitest/expect/node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -1488,7 +1466,7 @@ "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" @@ -1536,6 +1514,15 @@ "node": "*" } }, + "node_modules/@vitest/expect/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/@vitest/runner": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", @@ -1566,9 +1553,9 @@ } }, "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", "dev": true, "engines": { "node": ">=12.20" @@ -1698,9 +1685,9 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead" }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "bin": { "acorn": "bin/acorn" }, @@ -1726,9 +1713,12 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -1831,9 +1821,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "funding": [ { "type": "opencollective", @@ -1849,11 +1839,11 @@ } ], "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -1903,9 +1893,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "funding": [ { "type": "opencollective", @@ -1921,10 +1911,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -1951,9 +1941,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001632", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz", - "integrity": "sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "funding": [ { "type": "opencollective", @@ -2177,9 +2167,9 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "dependencies": { "ms": "2.1.2" }, @@ -2267,9 +2257,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.799", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.799.tgz", - "integrity": "sha512-3D3DwWkRTzrdEpntY0hMLYwj7SeBk1138CkPE8sBDSj3WzrzOiG2rHm3luw8jucpf+WiyLBCZyU9lMHyQI9M9Q==" + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==" }, "node_modules/entities": { "version": "4.5.0", @@ -2283,9 +2273,9 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2294,29 +2284,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -2359,15 +2349,15 @@ } }, "node_modules/eslint": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.4.0.tgz", - "integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/config-array": "^0.15.1", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.4.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2376,10 +2366,10 @@ "cross-spawn": "^7.0.2", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.0.1", + "eslint-scope": "^8.0.2", "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.1", - "esquery": "^1.4.2", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2405,7 +2395,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -2421,13 +2419,13 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", - "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.6" + "synckit": "^0.9.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -2475,13 +2473,13 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/scope-manager": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.0.tgz", - "integrity": "sha512-ZrMCe1R6a01T94ilV13egvcnvVJ1pxShkE0+NDjDzH4nvG1wXpwsVI5bZCvE7AEDH1mXEx5tJSVR68bLgG7Dng==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/visitor-keys": "7.13.0" + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2492,9 +2490,9 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.0.tgz", - "integrity": "sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2505,13 +2503,13 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.0.tgz", - "integrity": "sha512-cAvBvUoobaoIcoqox1YatXOnSl3gx92rCZoMRPzMNisDiM12siGilSM4+dJAekuuHTibI2hVC2fYK79iSFvWjw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/visitor-keys": "7.13.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2533,15 +2531,15 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.0.tgz", - "integrity": "sha512-jceD8RgdKORVnB4Y6BqasfIkFhl4pajB1wVxrF4akxD2QPM8GNYjgGwEzYS+437ewlqqrg7Dw+6dhdpjMpeBFQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.13.0", - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/typescript-estree": "7.13.0" + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2555,12 +2553,12 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.0.tgz", - "integrity": "sha512-nxn+dozQx+MK61nn/JP+M4eCkHDSxSLDpgE3WcQo0+fkjEolnaB5jswvIKC4K56By8MMgIho7f1PVxERHEo8rw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2593,9 +2591,9 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -2608,9 +2606,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz", - "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2634,11 +2632,11 @@ } }, "node_modules/espree": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", - "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", "dependencies": { - "acorn": "^8.11.3", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.0.0" }, @@ -2662,9 +2660,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dependencies": { "estraverse": "^5.1.0" }, @@ -3017,9 +3015,9 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dev": true, "dependencies": { "agent-base": "^7.0.2", @@ -3050,17 +3048,17 @@ } }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==" + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -3203,9 +3201,9 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz", - "integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", @@ -3530,9 +3528,9 @@ } }, "node_modules/jsdom": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz", - "integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==", + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", + "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", "dev": true, "dependencies": { "cssstyle": "^4.0.1", @@ -3541,11 +3539,11 @@ "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.10", + "nwsapi": "^2.2.12", "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.0", + "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.4", @@ -3554,7 +3552,7 @@ "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", - "ws": "^8.17.0", + "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -3664,12 +3662,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { @@ -3857,9 +3855,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/normalize-path": { "version": "3.0.0", @@ -3910,9 +3908,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", - "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==" + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==" }, "node_modules/once": { "version": "1.4.0", @@ -4069,20 +4067,20 @@ } }, "node_modules/pkg-types": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.1.tgz", - "integrity": "sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.3.tgz", + "integrity": "sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==", "dev": true, "dependencies": { "confbox": "^0.1.7", - "mlly": "^1.7.0", + "mlly": "^1.7.1", "pathe": "^1.1.2" } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "funding": [ { "type": "opencollective", @@ -4099,7 +4097,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -4120,9 +4118,9 @@ } }, "node_modules/prettier": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", - "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -4268,9 +4266,9 @@ } }, "node_modules/rollup": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", - "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.20.0.tgz", + "integrity": "sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==", "dependencies": { "@types/estree": "1.0.5" }, @@ -4282,22 +4280,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.18.0", - "@rollup/rollup-android-arm64": "4.18.0", - "@rollup/rollup-darwin-arm64": "4.18.0", - "@rollup/rollup-darwin-x64": "4.18.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", - "@rollup/rollup-linux-arm-musleabihf": "4.18.0", - "@rollup/rollup-linux-arm64-gnu": "4.18.0", - "@rollup/rollup-linux-arm64-musl": "4.18.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", - "@rollup/rollup-linux-riscv64-gnu": "4.18.0", - "@rollup/rollup-linux-s390x-gnu": "4.18.0", - "@rollup/rollup-linux-x64-gnu": "4.18.0", - "@rollup/rollup-linux-x64-musl": "4.18.0", - "@rollup/rollup-win32-arm64-msvc": "4.18.0", - "@rollup/rollup-win32-ia32-msvc": "4.18.0", - "@rollup/rollup-win32-x64-msvc": "4.18.0", + "@rollup/rollup-android-arm-eabi": "4.20.0", + "@rollup/rollup-android-arm64": "4.20.0", + "@rollup/rollup-darwin-arm64": "4.20.0", + "@rollup/rollup-darwin-x64": "4.20.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.20.0", + "@rollup/rollup-linux-arm-musleabihf": "4.20.0", + "@rollup/rollup-linux-arm64-gnu": "4.20.0", + "@rollup/rollup-linux-arm64-musl": "4.20.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.20.0", + "@rollup/rollup-linux-riscv64-gnu": "4.20.0", + "@rollup/rollup-linux-s390x-gnu": "4.20.0", + "@rollup/rollup-linux-x64-gnu": "4.20.0", + "@rollup/rollup-linux-x64-musl": "4.20.0", + "@rollup/rollup-win32-arm64-msvc": "4.20.0", + "@rollup/rollup-win32-ia32-msvc": "4.20.0", + "@rollup/rollup-win32-x64-msvc": "4.20.0", "fsevents": "~2.3.2" } }, @@ -4335,9 +4333,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.77.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.5.tgz", - "integrity": "sha512-oDfX1mukIlxacPdQqNb6mV2tVCrnE+P3nVYioy72V5tlk56CPNcO4TCuFcaCRKKfJ1M3lH95CleRS+dVKL2qMg==", + "version": "1.77.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -4362,9 +4360,9 @@ } }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -4546,9 +4544,9 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, "node_modules/synckit": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", - "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", "dev": true, "dependencies": { "@pkgr/core": "^0.1.0", @@ -4581,9 +4579,9 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, "node_modules/tinypool": { @@ -4687,9 +4685,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4721,15 +4719,15 @@ } }, "node_modules/ufo": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", - "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "dev": true }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==" }, "node_modules/universalify": { "version": "0.2.0", @@ -4740,9 +4738,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -4786,12 +4784,12 @@ } }, "node_modules/vite": { - "version": "5.2.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", - "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", + "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", + "esbuild": "^0.21.3", + "postcss": "^8.4.40", "rollup": "^4.13.0" }, "bin": { @@ -4811,6 +4809,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -4828,6 +4827,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -4951,9 +4953,9 @@ } }, "node_modules/vitest/node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -4962,7 +4964,7 @@ "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" @@ -5010,6 +5012,15 @@ "node": "*" } }, + "node_modules/vitest/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -5079,9 +5090,9 @@ } }, "node_modules/why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "dependencies": { "siginfo": "^2.0.0", @@ -5109,9 +5120,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "engines": { "node": ">=10.0.0" }, diff --git a/docker-compose.yml b/docker-compose.yml index 29853696..5b102408 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: - '5173:5173' postgres: - image: postgres:16.3-alpine + image: postgres:16.4-alpine environment: - POSTGRES_PASSWORD=postgres volumes: @@ -68,7 +68,7 @@ services: driver: "none" opensearch: - image: opensearchproject/opensearch:2.15.0 + image: opensearchproject/opensearch:2.16.0 volumes: - opensearch_data:/usr/share/opensearch/data - ./docker/opensearch/opensearch.yml:/usr/share/opensearch/config/opensearch.yml @@ -80,12 +80,12 @@ services: hard: 65536 valkey: - image: valkey/valkey:7.2.5-alpine + image: valkey/valkey:8.0-alpine logging: driver: "none" files: - image: andrewgaul/s3proxy:sha-4175022 + image: andrewgaul/s3proxy:sha-4976e17 environment: - JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3 volumes: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index edb02075..1b9a8d69 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,4 +1,4 @@ -FROM elixir:1.17-alpine +FROM elixir:1.17.2-alpine ADD https://api.github.com/repos/philomena-dev/FFmpeg/git/refs/heads/release/6.1 /tmp/ffmpeg_version.json RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \ diff --git a/mix.lock b/mix.lock index 6aff7050..40813e5f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,89 +1,81 @@ %{ - "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"}, + "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"}, "canary": {:git, "https://github.com/marcinkoziej/canary.git", "704debde7a2c0600f78c687807884bf37c45bd79", [ref: "704debde7a2c0600f78c687807884bf37c45bd79"]}, - "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, - "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "ecto": {:hex, :ecto, "3.12.1", "626765f7066589de6fa09e0876a253ff60c3d00870dd3a1cd696e2ba67bfceea", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df0045ab9d87be947228e05a8d153f3e06e0d05ab10c3b3cc557d2f7243d1940"}, "ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_aws": {:git, "https://github.com/liamwhite/ex_aws.git", "a340859dd8ac4d63bd7a3948f0994e493e49bda4", [ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4"]}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"}, - "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, - "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, + "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, + "expo": {:hex, :expo, "1.0.0", "647639267e088717232f4d4451526e7a9de31a3402af7fcbda09b27e9a10395a", [:mix], [], "hexpm", "18d2093d344d97678e8a331ca0391e85d29816f9664a25653fd7e6166827827c"}, "exq": {:hex, :exq, "0.19.0", "06eb92944dad39f0954dc8f63190d3e24d11734eef88cf5800883e57ebf74f3c", [:mix], [{:elixir_uuid, ">= 1.2.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0 and < 6.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:redix, ">= 0.9.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "24fc0ebdd87cc7406e1034fb46c2419f9c8a362f0ec634d23b6b819514d36390"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, - "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, - "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "gettext": {:hex, :gettext, "0.25.0", "98a95a862a94e2d55d24520dd79256a15c87ea75b49673a2e2f206e6ebc42e5d", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "38e5d754e66af37980a94fb93bb20dcde1d2361f664b0a19f01e87296634051f"}, + "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, "inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, - "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"}, - "mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"}, - "mua": {:hex, :mua, "0.2.2", "d2997abc1eee43d91e4a355665658743ad2609b8d5992425940ce17b7ff87933", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "cda7e38c65d3105b3017b25ac402b4c9457892abeb2e11c331b25a92d16b04c0"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, + "mua": {:hex, :mua, "0.2.3", "46b29b7b2bb14105c0b7be9526f7c452df17a7841b30b69871c024a822ff551c", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "7fe861a87fcc06a980d3941bbcb2634e5f0f30fd6ad15ef6c0423ff9dc7e46de"}, "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]}, - "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, + "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"}, "phoenix_slime": {:git, "https://github.com/slime-lang/phoenix_slime.git", "8944de91654d6fcf6bdcc0aed6b8647fe3398241", [ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"]}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, - "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, + "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, + "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, "pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"}, "qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"}, "redix": {:hex, :redix, "1.5.1", "a2386971e69bf23630fb3a215a831b5478d2ee7dc9ea7ac811ed89186ab5d7b7", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "85224eb2b683c516b80d472eb89b76067d5866913bf0be59d646f550de71f5c4"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, - "req": {:hex, :req, "0.5.1", "90584216d064389a4ff2d4279fe2c11ff6c812ab00fa01a9fb9d15457f65ba70", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7ea96a1a95388eb0fefa92d89466cdfedba24032794e5c1147d78ec90db7edca"}, - "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, - "rustler": {:hex, :rustler, "0.33.0", "4a5b0a7a7b0b51549bea49947beff6fae9bc5d5326104dcd4531261e876b5619", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "7c4752728fee59a815ffd20c3429c55b644041f25129b29cdeb5c470b80ec5fd"}, + "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, + "rustler": {:hex, :rustler, "0.34.0", "e9a73ee419fc296a10e49b415a2eb87a88c9217aa0275ec9f383d37eed290c1c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "1d0c7449482b459513003230c0e2422b0252245776fe6fd6e41cb2b11bd8e628"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, "secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"}, "slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, - "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, + "swoosh": {:hex, :swoosh, "1.16.10", "04be6e2eb1a31aa0aa21a731175c81cc3998189456a92daf13d44a5c754afcf5", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "756be04db173c0cbe318f1dfe2bcc88aa63aed78cf5a4b02b61b36ee11fc716a"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, } From 70145f3926a41b4c4bb9a5bbc4c36c04a5ea6fd2 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 13 Aug 2024 21:20:00 -0400 Subject: [PATCH 066/115] Fix sass deprecations https://sass-lang.com/documentation/breaking-changes/mixed-decls/ --- assets/css/views/_commissions.scss | 2 +- assets/css/views/_communications.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/css/views/_commissions.scss b/assets/css/views/_commissions.scss index 205f2093..a8f8c38a 100644 --- a/assets/css/views/_commissions.scss +++ b/assets/css/views/_commissions.scss @@ -31,8 +31,8 @@ } .commission__listing__body_text { - img { max-width: 100%; } word-wrap: break-word; margin-top: 3px; margin-bottom: 6px; + img { max-width: 100%; } } diff --git a/assets/css/views/_communications.scss b/assets/css/views/_communications.scss index a53597db..c1a56a58 100644 --- a/assets/css/views/_communications.scss +++ b/assets/css/views/_communications.scss @@ -26,11 +26,11 @@ span.communication__body__sender-name { } .communication__body__text { - img { max-width: 100%; } word-wrap: break-word; margin-top: 3px; margin-bottom: 6px; line-height: 1.35em; + img { max-width: 100%; } } span.communication__sender__stats, From 6e64e4b6f0075733bfc3b3b9eae5463924ae4158 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 13 Aug 2024 17:16:47 -0400 Subject: [PATCH 067/115] Increase memory efficiency of local autocomplete --- assets/eslint.config.js | 2 +- assets/js/autocomplete.js | 3 +- .../__tests__/local-autocompleter.spec.ts | 30 +++--- assets/js/utils/__tests__/unique-heap.spec.ts | 70 ++++++++++++++ assets/js/utils/local-autocompleter.ts | 90 ++++++++++------- assets/js/utils/unique-heap.ts | 96 +++++++++++++++++++ 6 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 assets/js/utils/__tests__/unique-heap.spec.ts create mode 100644 assets/js/utils/unique-heap.ts diff --git a/assets/eslint.config.js b/assets/eslint.config.js index c927efb6..2c7a6e63 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -125,7 +125,7 @@ export default tsEslint.config( 'no-irregular-whitespace': 2, 'no-iterator': 2, 'no-label-var': 2, - 'no-labels': 2, + 'no-labels': [2, { allowSwitch: true, allowLoop: true }], 'no-lone-blocks': 2, 'no-lonely-if': 0, 'no-loop-func': 2, diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 1a95fb04..8ec0f278 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -237,7 +237,8 @@ function listenAutocomplete() { } const suggestions = localAc - .topK(originalTerm, suggestionsCount) + .matchPrefix(originalTerm) + .topK(suggestionsCount) .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { diff --git a/assets/js/utils/__tests__/local-autocompleter.spec.ts b/assets/js/utils/__tests__/local-autocompleter.spec.ts index 2310c92d..5bef0fe1 100644 --- a/assets/js/utils/__tests__/local-autocompleter.spec.ts +++ b/assets/js/utils/__tests__/local-autocompleter.spec.ts @@ -58,42 +58,44 @@ describe('Local Autocompleter', () => { }); it('should return suggestions for exact tag name match', () => { - const result = localAc.topK('safe', defaultK); - expect(result).toEqual([expect.objectContaining({ name: 'safe', imageCount: 6 })]); + const result = localAc.matchPrefix('safe').topK(defaultK); + expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]); }); it('should return suggestion for original tag when passed an alias', () => { - const result = localAc.topK('flowers', defaultK); - expect(result).toEqual([expect.objectContaining({ name: 'flower', imageCount: 1 })]); + const result = localAc.matchPrefix('flowers').topK(defaultK); + expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]); }); it('should return suggestions sorted by image count', () => { - const result = localAc.topK(termStem, defaultK); + const result = localAc.matchPrefix(termStem).topK(defaultK); expect(result).toEqual([ - expect.objectContaining({ name: 'forest', imageCount: 3 }), - expect.objectContaining({ name: 'fog', imageCount: 1 }), - expect.objectContaining({ name: 'force field', imageCount: 1 }), + expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }), + expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }), + expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }), ]); }); it('should return namespaced suggestions without including namespace', () => { - const result = localAc.topK('test', defaultK); - expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]); + const result = localAc.matchPrefix('test').topK(defaultK); + expect(result).toEqual([ + expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }), + ]); }); it('should return only the required number of suggestions', () => { - const result = localAc.topK(termStem, 1); - expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]); + const result = localAc.matchPrefix(termStem).topK(1); + expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]); }); it('should NOT return suggestions associated with hidden tags', () => { window.booru.hiddenTagList = [1]; - const result = localAc.topK(termStem, defaultK); + const result = localAc.matchPrefix(termStem).topK(defaultK); expect(result).toEqual([]); }); it('should return empty array for empty prefix', () => { - const result = localAc.topK('', defaultK); + const result = localAc.matchPrefix('').topK(defaultK); expect(result).toEqual([]); }); }); diff --git a/assets/js/utils/__tests__/unique-heap.spec.ts b/assets/js/utils/__tests__/unique-heap.spec.ts new file mode 100644 index 00000000..e7127ef6 --- /dev/null +++ b/assets/js/utils/__tests__/unique-heap.spec.ts @@ -0,0 +1,70 @@ +import { UniqueHeap } from '../unique-heap'; + +describe('Unique Heap', () => { + interface Result { + name: string; + } + + function compare(a: Result, b: Result): boolean { + return a.name < b.name; + } + + test('it should return no results when empty', () => { + const heap = new UniqueHeap(compare, 'name'); + expect(heap.topK(5)).toEqual([]); + }); + + test("doesn't insert duplicate results", () => { + const heap = new UniqueHeap(compare, 'name'); + + heap.append({ name: 'name' }); + heap.append({ name: 'name' }); + + expect(heap.topK(2)).toEqual([expect.objectContaining({ name: 'name' })]); + }); + + test('it should return results in reverse sorted order', () => { + const heap = new UniqueHeap(compare, 'name'); + + const names = [ + 'alpha', + 'beta', + 'gamma', + 'delta', + 'epsilon', + 'zeta', + 'eta', + 'theta', + 'iota', + 'kappa', + 'lambda', + 'mu', + 'nu', + 'xi', + 'omicron', + 'pi', + 'rho', + 'sigma', + 'tau', + 'upsilon', + 'phi', + 'chi', + 'psi', + 'omega', + ]; + + for (const name of names) { + heap.append({ name }); + } + + const results = heap.topK(5); + + expect(results).toEqual([ + expect.objectContaining({ name: 'zeta' }), + expect.objectContaining({ name: 'xi' }), + expect.objectContaining({ name: 'upsilon' }), + expect.objectContaining({ name: 'theta' }), + expect.objectContaining({ name: 'tau' }), + ]); + }); +}); diff --git a/assets/js/utils/local-autocompleter.ts b/assets/js/utils/local-autocompleter.ts index ec3ba162..8b752136 100644 --- a/assets/js/utils/local-autocompleter.ts +++ b/assets/js/utils/local-autocompleter.ts @@ -1,12 +1,21 @@ // Client-side tag completion. +import { UniqueHeap } from './unique-heap'; import store from './store'; -interface Result { +export interface Result { + aliasName: string; name: string; imageCount: number; associations: number[]; } +/** + * Returns whether Result a is considered less than Result b. + */ +function compareResult(a: Result, b: Result): boolean { + return a.imageCount === b.imageCount ? a.name > b.name : a.imageCount < b.imageCount; +} + /** * Compare two strings, C-style. */ @@ -18,10 +27,13 @@ function strcmp(a: string, b: string): number { * Returns the name of a tag without any namespace component. */ function nameInNamespace(s: string): string { - const v = s.split(':', 2); + const first = s.indexOf(':'); - if (v.length === 2) return v[1]; - return v[0]; + if (first !== -1) { + return s.slice(first + 1); + } + + return s; } /** @@ -59,7 +71,7 @@ export class LocalAutocompleter { /** * Get a tag's name and its associations given a byte location inside the file. */ - getTagFromLocation(location: number): [string, number[]] { + private getTagFromLocation(location: number, imageCount: number, aliasName?: string): Result { const nameLength = this.view.getUint8(location); const assnLength = this.view.getUint8(location + 1 + nameLength); @@ -70,29 +82,29 @@ export class LocalAutocompleter { associations.push(this.view.getUint32(location + 1 + nameLength + 1 + i * 4, true)); } - return [name, associations]; + return { aliasName: aliasName || name, name, imageCount, associations }; } /** * Get a Result object as the ith tag inside the file. */ - getResultAt(i: number): [string, Result] { - const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true); + private getResultAt(i: number, aliasName?: string): Result { + const tagLocation = this.view.getUint32(this.referenceStart + i * 8, true); const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true); - const [name, associations] = this.getTagFromLocation(nameLocation); + const result = this.getTagFromLocation(tagLocation, imageCount, aliasName); if (imageCount < 0) { // This is actually an alias, so follow it - return [name, this.getResultAt(-imageCount - 1)[1]]; + return this.getResultAt(-imageCount - 1, aliasName || result.name); } - return [name, { name, imageCount, associations }]; + return result; } /** * Get a Result object as the ith tag inside the file, secondary ordering. */ - getSecondaryResultAt(i: number): [string, Result] { + private getSecondaryResultAt(i: number): Result { const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true); return this.getResultAt(referenceIndex); } @@ -100,23 +112,22 @@ export class LocalAutocompleter { /** * Perform a binary search to fetch all results matching a condition. */ - scanResults( - getResult: (i: number) => [string, Result], + private scanResults( + getResult: (i: number) => Result, compare: (name: string) => number, - results: Record, + results: UniqueHeap, + hiddenTags: Set, ) { - const unfilter = store.get('unfilter_tag_suggestions'); + const filter = !store.get('unfilter_tag_suggestions'); let min = 0; let max = this.numTags; - const hiddenTags = window.booru.hiddenTagList; - while (min < max - 1) { - const med = (min + (max - min) / 2) | 0; - const sortKey = getResult(med)[0]; + const med = min + (((max - min) / 2) | 0); + const result = getResult(med); - if (compare(sortKey) >= 0) { + if (compare(result.aliasName) >= 0) { // too large, go left max = med; } else { @@ -126,40 +137,47 @@ export class LocalAutocompleter { } // Scan forward until no more matches occur - while (min < this.numTags - 1) { - const [sortKey, result] = getResult(++min); - if (compare(sortKey) !== 0) { + outer: while (min < this.numTags - 1) { + const result = getResult(++min); + + if (compare(result.aliasName) !== 0) { break; } - // Add if not filtering or no associations are filtered - if (unfilter || hiddenTags.findIndex(ht => result.associations.includes(ht)) === -1) { - results[result.name] = result; + // Check if any associations are filtered + if (filter) { + for (const association of result.associations) { + if (hiddenTags.has(association)) { + continue outer; + } + } } + + // Nothing was filtered, so add + results.append(result); } } /** * Find the top k results by image count which match the given string prefix. */ - topK(prefix: string, k: number): Result[] { - const results: Record = {}; + matchPrefix(prefix: string): UniqueHeap { + const results = new UniqueHeap(compareResult, 'name'); if (prefix === '') { - return []; + return results; } + const hiddenTags = new Set(window.booru.hiddenTagList); + // Find normally, in full name-sorted order const prefixMatch = (name: string) => strcmp(name.slice(0, prefix.length), prefix); - this.scanResults(this.getResultAt.bind(this), prefixMatch, results); + this.scanResults(this.getResultAt.bind(this), prefixMatch, results, hiddenTags); // Find in secondary order const namespaceMatch = (name: string) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix); - this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results); + this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results, hiddenTags); - // Sort results by image count - const sorted = Object.values(results).sort((a, b) => b.imageCount - a.imageCount); - - return sorted.slice(0, k); + return results; } } diff --git a/assets/js/utils/unique-heap.ts b/assets/js/utils/unique-heap.ts new file mode 100644 index 00000000..3b4e840c --- /dev/null +++ b/assets/js/utils/unique-heap.ts @@ -0,0 +1,96 @@ +export type Compare = (a: T, b: T) => boolean; + +export class UniqueHeap { + private keys: Set; + private values: T[]; + private keyName: keyof T; + private compare: Compare; + + constructor(compare: Compare, keyName: keyof T) { + this.keys = new Set(); + this.values = []; + this.keyName = keyName; + this.compare = compare; + } + + append(value: T) { + const key = value[this.keyName]; + + if (!this.keys.has(key)) { + this.keys.add(key); + this.values.push(value); + } + } + + topK(k: number): T[] { + // Create the output array. + const output: T[] = []; + + for (const result of this.results()) { + if (output.length >= k) { + break; + } + + output.push(result); + } + + return output; + } + + *results(): Generator { + const { values } = this; + const length = values.length; + + // Build the heap. + for (let i = (length >> 1) - 1; i >= 0; i--) { + this.heapify(length, i); + } + + // Begin extracting values. + for (let i = 0; i < length; i++) { + // Top value is the largest. + yield values[0]; + + // Swap with the element at the end. + const lastIndex = length - i - 1; + values[0] = values[lastIndex]; + + // Restore top value being the largest. + this.heapify(lastIndex, 0); + } + } + + private heapify(length: number, initialIndex: number) { + const { compare, values } = this; + let i = initialIndex; + + while (true) { + const left = 2 * i + 1; + const right = 2 * i + 2; + let largest = i; + + if (left < length && compare(values[largest], values[left])) { + // Left child is in-bounds and larger than parent. Swap with left. + largest = left; + } + + if (right < length && compare(values[largest], values[right])) { + // Right child is in-bounds and larger than parent or left. Swap with right. + largest = right; + } + + if (largest === i) { + // Largest value was already the parent. Done. + return; + } + + // Swap. + const temp = values[i]; + values[i] = values[largest]; + values[largest] = temp; + + // Repair the subtree previously containing the largest element. + i = largest; + } + } +} From 74120d75225b25fce81cc8b47151333e2f28396a Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 15 Aug 2024 17:22:32 -0400 Subject: [PATCH 068/115] Bump scrivener_ecto for 3.12 --- mix.exs | 3 ++- mix.lock | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 05f26cc9..4032d859 100644 --- a/mix.exs +++ b/mix.exs @@ -55,7 +55,8 @@ defmodule Philomena.MixProject do {:pot, "~> 1.0"}, {:secure_compare, "~> 0.1"}, {:nimble_parsec, "~> 1.2"}, - {:scrivener_ecto, "~> 2.7"}, + {:scrivener_ecto, + github: "krns/scrivener_ecto", ref: "eaad1ddd86a9c8ffa422479417221265a0673777"}, {:pbkdf2, ">= 0.0.0", github: "basho/erlang-pbkdf2", ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"}, {:qrcode, "~> 0.1"}, diff --git a/mix.lock b/mix.lock index 40813e5f..7f179ae0 100644 --- a/mix.lock +++ b/mix.lock @@ -65,7 +65,7 @@ "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "rustler": {:hex, :rustler, "0.34.0", "e9a73ee419fc296a10e49b415a2eb87a88c9217aa0275ec9f383d37eed290c1c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "1d0c7449482b459513003230c0e2422b0252245776fe6fd6e41cb2b11bd8e628"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, - "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, + "scrivener_ecto": {:git, "https://github.com/krns/scrivener_ecto.git", "eaad1ddd86a9c8ffa422479417221265a0673777", [ref: "eaad1ddd86a9c8ffa422479417221265a0673777"]}, "secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"}, "slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, From 2e1808b00fb3425bc87979189b813f2daf4d317d Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 15 Aug 2024 23:01:34 -0400 Subject: [PATCH 069/115] Fix case match --- lib/philomena_web/controllers/admin/advert_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/philomena_web/controllers/admin/advert_controller.ex b/lib/philomena_web/controllers/admin/advert_controller.ex index e058ce26..12b1dfbe 100644 --- a/lib/philomena_web/controllers/admin/advert_controller.ex +++ b/lib/philomena_web/controllers/admin/advert_controller.ex @@ -34,7 +34,7 @@ defmodule PhilomenaWeb.Admin.AdvertController do |> put_flash(:info, "Advert was successfully created.") |> redirect(to: ~p"/admin/adverts") - {:error, :advert, changeset, _changes} -> + {:error, changeset} -> render(conn, "new.html", changeset: changeset) end end From 967cbf7b24d456e615e4737400fae424def5ba08 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 16 Aug 2024 13:42:02 -0400 Subject: [PATCH 070/115] Remove transport_opts workaround for SSL hosts due to upstream fix Available in 27.0.1+ by https://github.com/erlang/otp/issues/8588 --- lib/philomena_proxy/http.ex | 41 ++++++++----------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/lib/philomena_proxy/http.ex b/lib/philomena_proxy/http.ex index 5558f697..a9c03e69 100644 --- a/lib/philomena_proxy/http.ex +++ b/lib/philomena_proxy/http.ex @@ -84,7 +84,7 @@ defmodule PhilomenaProxy.Http do body: body, headers: [{:user_agent, @user_agent} | headers], max_redirects: 1, - connect_options: connect_options(url), + connect_options: connect_options(), inet6: true, into: &stream_response_callback/2, decode_body: false @@ -93,39 +93,14 @@ defmodule PhilomenaProxy.Http do |> Req.request() end - defp connect_options(url) do - transport_opts = - case URI.parse(url) do - %{scheme: "https"} -> - # SSL defaults validate SHA-1 on root certificates but this is unnecessary because many - # many roots are still signed with SHA-1 and it isn't relevant for security. Relax to - # allow validation of SHA-1, even though this creates a less secure client. - # https://github.com/erlang/otp/issues/8601 - [ - transport_opts: [ - customize_hostname_check: [ - match_fun: :public_key.pkix_verify_hostname_match_fun(:https) - ], - signature_algs_cert: :ssl.signature_algs(:default, :"tlsv1.3") ++ [sha: :rsa] - ] - ] + defp connect_options do + case Application.get_env(:philomena, :proxy_host) do + nil -> + [] - _ -> - # Do not pass any options for non-HTTPS schemes. Finch will raise badarg if the above - # options are passed. - [] - end - - proxy_opts = - case Application.get_env(:philomena, :proxy_host) do - nil -> - [] - - url -> - [proxy: proxy_opts(URI.parse(url))] - end - - transport_opts ++ proxy_opts + proxy_url -> + [proxy: proxy_opts(URI.parse(proxy_url))] + end end defp proxy_opts(%{host: host, port: port, scheme: "https"}), From 25748dc8ff03a0a1f5e3b4c7d9a1851b2a29e887 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 17 Aug 2024 17:28:56 -0400 Subject: [PATCH 071/115] Fix HEAD requests to s3proxy --- docker/web/aws-signature.lua | 2 +- docker/web/nginx.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/web/aws-signature.lua b/docker/web/aws-signature.lua index fae28992..c204cbb6 100644 --- a/docker/web/aws-signature.lua +++ b/docker/web/aws-signature.lua @@ -76,7 +76,7 @@ end local function get_hashed_canonical_request(timestamp, host, uri) local digest = get_sha256_digest(ngx.var.request_body) - local canonical_request = ngx.var.request_method .. '\n' + local canonical_request = ngx.req.get_method() .. '\n' .. uri .. '\n' .. '\n' .. 'host:' .. host .. '\n' diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf index 218fe896..73bd5aea 100644 --- a/docker/web/nginx.conf +++ b/docker/web/nginx.conf @@ -34,7 +34,7 @@ init_by_lua_block { function sign_aws_request() -- The API token used should not allow writing, but -- sanitize this anyway to stop an upstream error - if ngx.req.get_method() ~= 'GET' then + if ngx.req.get_method() ~= 'GET' and ngx.req.get_method() ~= 'HEAD' then ngx.status = ngx.HTTP_UNAUTHORIZED ngx.say('Unauthorized') return ngx.exit(ngx.HTTP_UNAUTHORIZED) From d78201d05fac14d5529bb693255123214265c382 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 17 Aug 2024 17:38:34 -0400 Subject: [PATCH 072/115] Only allow GET The proxy_cache module will always internally convert HEAD to GET (which is desired). This does not update the request method variables exposed to Lua, so hardcode GET. --- docker/web/aws-signature.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/web/aws-signature.lua b/docker/web/aws-signature.lua index c204cbb6..31a46f58 100644 --- a/docker/web/aws-signature.lua +++ b/docker/web/aws-signature.lua @@ -76,7 +76,7 @@ end local function get_hashed_canonical_request(timestamp, host, uri) local digest = get_sha256_digest(ngx.var.request_body) - local canonical_request = ngx.req.get_method() .. '\n' + local canonical_request = 'GET' .. '\n' .. uri .. '\n' .. '\n' .. 'host:' .. host .. '\n' From 5da5d086c8a8273e5ad380a390da95a6f473eda0 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 25 Aug 2024 21:10:34 -0400 Subject: [PATCH 073/115] Fix preloads --- .../controllers/admin/user/erase_controller.ex | 12 ++++++++++++ lib/philomena_web/controllers/profile_controller.ex | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex index b481068e..f0a926df 100644 --- a/lib/philomena_web/controllers/admin/user/erase_controller.ex +++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex @@ -13,6 +13,7 @@ defmodule PhilomenaWeb.Admin.User.EraseController do persisted: true, preload: [:roles] + plug :prevent_deleting_nonexistent_users plug :prevent_deleting_privileged_users plug :prevent_deleting_verified_users @@ -35,6 +36,17 @@ defmodule PhilomenaWeb.Admin.User.EraseController do end end + defp prevent_deleting_nonexistent_users(conn, _opts) do + if is_nil(conn.assigns.user) do + conn + |> put_flash(:error, "Couldn't find that username. Was it already erased?") + |> redirect(to: ~p"/admin/users") + |> Plug.Conn.halt() + else + conn + end + end + defp prevent_deleting_privileged_users(conn, _opts) do if conn.assigns.user.role != "user" do conn diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index af5b0ac3..d3e375f4 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -125,8 +125,12 @@ defmodule PhilomenaWeb.ProfileController do preload(Image, [:sources, tags: :aliases]), preload(Image, [:sources, tags: :aliases]), preload(Image, [:sources, tags: :aliases]), - preload(Comment, user: [awards: :badge], image: [:sources, tags: :aliases]), - preload(Post, user: [awards: :badge], topic: :forum) + preload(Comment, [ + :deleted_by, + user: [awards: :badge], + image: [:sources, tags: :aliases] + ]), + preload(Post, [:deleted_by, user: [awards: :badge], topic: :forum]) ] ) From c81b991b8832039646d3f46a54e4160648707263 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 25 Aug 2024 23:50:33 -0400 Subject: [PATCH 074/115] Use image list layout in reverse search --- lib/philomena/duplicate_reports.ex | 2 +- .../controllers/search/reverse_controller.ex | 23 ++++++- .../templates/search/reverse/index.html.slime | 61 ++++++++----------- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index c6cb2c55..a9cad67b 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -87,7 +87,7 @@ defmodule Philomena.DuplicateReports do {intensities, aspect} |> find_duplicates(dist: dist, aspect_dist: dist, limit: limit) |> preload([:user, :intensity, [:sources, tags: :aliases]]) - |> Repo.all() + |> Repo.paginate(page_size: 50) {:ok, images} diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex index 967b968a..ef4fa836 100644 --- a/lib/philomena_web/controllers/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/search/reverse_controller.ex @@ -16,15 +16,32 @@ defmodule PhilomenaWeb.Search.ReverseController do case DuplicateReports.execute_search_query(image_params) do {:ok, images} -> changeset = DuplicateReports.change_search_query(%SearchQuery{}) - render(conn, "index.html", title: "Reverse Search", images: images, changeset: changeset) + + render(conn, "index.html", + title: "Reverse Search", + layout_class: "layout--wide", + images: images, + changeset: changeset + ) {:error, changeset} -> - render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset) + render(conn, "index.html", + title: "Reverse Search", + layout_class: "layout--wide", + images: nil, + changeset: changeset + ) end end def create(conn, _params) do changeset = DuplicateReports.change_search_query(%SearchQuery{}) - render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset) + + render(conn, "index.html", + title: "Reverse Search", + layout_class: "layout--wide", + images: nil, + changeset: changeset + ) end end diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime index 97d88686..7e714600 100644 --- a/lib/philomena_web/templates/search/reverse/index.html.slime +++ b/lib/philomena_web/templates/search/reverse/index.html.slime @@ -1,12 +1,13 @@ h1 Reverse Search = form_for @changeset, ~p"/search/reverse", [multipart: true, as: :image], fn f -> - p - ' Basic image similarity search. Finds uploaded images similar to the one - ' provided based on simple intensities and uses the median frame of - ' animations; very low contrast images (such as sketches) will produce - ' poor results and, regardless of contrast, results may include seemingly - ' random images that look very different. + .walloftext + p + ' Basic image similarity search. Finds uploaded images similar to the one + ' provided based on simple intensities and uses the median frame of + ' animations; very low contrast images (such as sketches) will produce + ' poor results and, regardless of contrast, results may include seemingly + ' random images that look very different. .image-other #js-image-upload-previews @@ -40,42 +41,28 @@ h1 Reverse Search = cond do - is_nil(@images) -> + / Don't render anything. - Enum.any?(@images) -> - h2 Results + .block#imagelist-container + section.block__header.page__header.flex + span.block__header__title.page__title.hide-mobile + ' Search by uploaded image - table - tr - th   - th Image - th   + .block__content.js-resizable-media-container + = for image <- @images do + = render PhilomenaWeb.ImageView, "_image_box.html", image: image, link: ~p"/images/#{image}", size: :thumb, conn: @conn - = for match <- @images do - tr - th - h3 = link "##{match.id}", to: ~p"/images/#{match}" - p - = if image_has_sources(match) do - span.source_url - = link "Source", to: image_first_source(match) - - else - ' Unknown source + .block__header.block__header--light.page__header.flex + span.block__header__title.page__info + = render PhilomenaWeb.PaginationView, "_pagination_info.html", page: @images - th - = render PhilomenaWeb.ImageView, "_image_container.html", image: match, size: :thumb, conn: @conn - - th - h3 - = match.image_width - | x - => match.image_height - ' - - => round(match.image_size / 1024) - ' KiB - - = render PhilomenaWeb.TagView, "_tag_list.html", tags: Tag.display_order(match.tags), conn: @conn + .flex__right.page__options + a href="/settings/edit" title="Display Settings" + i.fa.fa-cog + span.hide-mobile.hide-limited-desktop<> + ' Display Settings - true -> - h2 Results p - ' We couldn't find any images matching this in our image database. + ' No images found! From 4dd0c8c11b4e46ca3e72e724a78a5155bf9cafdc Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 27 Aug 2024 08:15:19 -0400 Subject: [PATCH 075/115] Fix warning --- lib/philomena_web/views/search/reverse_view.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/philomena_web/views/search/reverse_view.ex b/lib/philomena_web/views/search/reverse_view.ex index 498deefa..7cb4704f 100644 --- a/lib/philomena_web/views/search/reverse_view.ex +++ b/lib/philomena_web/views/search/reverse_view.ex @@ -1,5 +1,3 @@ defmodule PhilomenaWeb.Search.ReverseView do use PhilomenaWeb, :view - - alias Philomena.Tags.Tag end From 1550125b52eebc38a4c92abbed33e4a401c5ea8b Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 27 Aug 2024 09:05:45 -0400 Subject: [PATCH 076/115] Fix API reverse search --- .../controllers/api/json/search/reverse_controller.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index 1b7a6011..13095c2e 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -27,6 +27,10 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do conn |> put_view(PhilomenaWeb.Api.Json.ImageView) - |> render("index.json", images: images, total: length(images), interactions: interactions) + |> render("index.json", + images: images, + total: images.total_entries, + interactions: interactions + ) end end From 55760ea57e2bf09338c6d031e4e31c0bc9cb7f43 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 14:58:27 -0400 Subject: [PATCH 077/115] client-side tag input validation on image upload submit, preserving the image in the form --- assets/js/upload.js | 116 +++++++++++++++++- .../templates/image/new.html.slime | 2 +- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/assets/js/upload.js b/assets/js/upload.js index 16d33959..81d320e5 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -171,9 +171,123 @@ function setupImageUpload() { window.removeEventListener('beforeunload', beforeUnload); } + function createTagError(message) { + const buttonAfter = $('#tagsinput-save'); + const errorElement = makeEl('span', { className: 'help-block tag-error'}); + errorElement.innerText = message; + buttonAfter.parentElement.insertBefore(errorElement, buttonAfter); + } + + function clearTagErrors() { + const tagErrorElements = document.getElementsByClassName('tag-error'); + + // remove() causes the length to decrease + while(tagErrorElements.length > 0) { + tagErrorElements[0].remove(); + } + } + + // populate tag error helper bars as necessary + // return true if all checks pass + // return false if any check fails + function validateTags() { + const tags = $$('.tag'); + if (tags.length === 0) { + createTagError("Tag input must contain at least 3 tags") + return false; + } + + let tags_arr = []; + for (const i in tags) { + let tag = tags[i].innerText; + tag = tag.substring(0, tag.length - 2); // remove " x" from the end + tags_arr.push(tag); + } + + let ratings_tags = ["safe", "suggestive", "questionable", "explicit", + "semi-grimdark", "grimdark", "grotesque"]; + + let errors = []; + + let hasRating = false; + let hasSafe = false; + let hasOtherRating = false; + tags_arr.forEach(tag => { + if (ratings_tags.includes(tag)) { + hasRating = true; + if (tag === "safe") { + hasSafe = true; + } else { + hasOtherRating = true; + } + } + }); + + if (!hasRating) { + errors.push("Tag input must contain at least one rating tag"); + } else if (hasSafe && hasOtherRating) { + errors.push("Tag input may not contain any other rating if safe") + } + + if (tags_arr.length < 3) { + errors.push("Tag input must contain at least 3 tags"); + } + + errors.forEach(msg => createTagError(msg)); + + return errors.length == 0; // true: valid if no errors + } + + function enableUploadButton() { + const submitButton = $('.input--separate-top'); + if (submitButton !== null) { + submitButton.disabled = false; + submitButton.innerText = 'Upload'; + } + } + + function disableUploadButton() { + const submitButton = $('.input--separate-top'); + if (submitButton !== null) { + submitButton.disabled = true; + submitButton.innerText = "Please wait..."; + } + + // delay is needed because Safari stops the submit if the button is immediately disabled + requestAnimationFrame(() => submitButton.setAttribute('disabled', 'disabled')); + } + + function anchorToTop() { + let url = window.location.href; + url = url.split('#')[0]; //remove any existing hash anchor from url + url += '#'; //move view to top of page + window.location.href = url; + } + + function submitHandler(event) { + clearTagErrors(); // remove any existing tag error elements + + if (validateTags() === true) { + // tags valid; + unregisterBeforeUnload(); + + // allow form submission + disableUploadButton(); + return true; + } else { + //tags invalid + enableUploadButton(); // enable Upload button + anchorToTop(); // move view to top of page + + // prevent form submission + event.preventDefault(); + return false; + } + } + fileField.addEventListener('change', registerBeforeUnload); fetchButton.addEventListener('click', registerBeforeUnload); - form.addEventListener('submit', unregisterBeforeUnload); + form.addEventListener('submit', submitHandler); } export { setupImageUpload }; diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime index dfb664d9..2a080eb1 100644 --- a/lib/philomena_web/templates/image/new.html.slime +++ b/lib/philomena_web/templates/image/new.html.slime @@ -88,4 +88,4 @@ = render PhilomenaWeb.CaptchaView, "_captcha.html", name: "image", conn: @conn .actions - = submit "Upload", class: "button input--separate-top", autocomplete: "off", data: [disable_with: "Please wait..."] + = submit "Upload", class: "button input--separate-top", autocomplete: "off" From a4cad4e534fd66527b1436ee203d0dd2e3774df0 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 15:41:45 -0400 Subject: [PATCH 078/115] linting fixes --- assets/js/upload.js | 49 ++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/assets/js/upload.js b/assets/js/upload.js index 81d320e5..c31995bb 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -173,7 +173,7 @@ function setupImageUpload() { function createTagError(message) { const buttonAfter = $('#tagsinput-save'); - const errorElement = makeEl('span', { className: 'help-block tag-error'}); + const errorElement = makeEl('span', { className: 'help-block tag-error' }); errorElement.innerText = message; buttonAfter.parentElement.insertBefore(errorElement, buttonAfter); } @@ -182,7 +182,7 @@ function setupImageUpload() { const tagErrorElements = document.getElementsByClassName('tag-error'); // remove() causes the length to decrease - while(tagErrorElements.length > 0) { + while (tagErrorElements.length > 0) { tagErrorElements[0].remove(); } } @@ -193,29 +193,28 @@ function setupImageUpload() { function validateTags() { const tags = $$('.tag'); if (tags.length === 0) { - createTagError("Tag input must contain at least 3 tags") + createTagError('Tag input must contain at least 3 tags'); return false; } - let tags_arr = []; + const tagsArr = []; for (const i in tags) { let tag = tags[i].innerText; tag = tag.substring(0, tag.length - 2); // remove " x" from the end - tags_arr.push(tag); + tagsArr.push(tag); } - let ratings_tags = ["safe", "suggestive", "questionable", "explicit", - "semi-grimdark", "grimdark", "grotesque"]; + const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque']; - let errors = []; + const errors = []; let hasRating = false; let hasSafe = false; let hasOtherRating = false; - tags_arr.forEach(tag => { - if (ratings_tags.includes(tag)) { + tagsArr.forEach(tag => { + if (ratingsTags.includes(tag)) { hasRating = true; - if (tag === "safe") { + if (tag === 'safe') { hasSafe = true; } else { hasOtherRating = true; @@ -224,18 +223,18 @@ function setupImageUpload() { }); if (!hasRating) { - errors.push("Tag input must contain at least one rating tag"); + errors.push('Tag input must contain at least one rating tag'); } else if (hasSafe && hasOtherRating) { - errors.push("Tag input may not contain any other rating if safe") + errors.push('Tag input may not contain any other rating if safe'); } - if (tags_arr.length < 3) { - errors.push("Tag input must contain at least 3 tags"); + if (tagsArr.length < 3) { + errors.push('Tag input must contain at least 3 tags'); } errors.forEach(msg => createTagError(msg)); - return errors.length == 0; // true: valid if no errors + return errors.length === 0; // true: valid if no errors } function enableUploadButton() { @@ -250,7 +249,7 @@ function setupImageUpload() { const submitButton = $('.input--separate-top'); if (submitButton !== null) { submitButton.disabled = true; - submitButton.innerText = "Please wait..."; + submitButton.innerText = 'Please wait...'; } // delay is needed because Safari stops the submit if the button is immediately disabled @@ -274,15 +273,15 @@ function setupImageUpload() { // allow form submission disableUploadButton(); return true; - } else { - //tags invalid - enableUploadButton(); // enable Upload button - anchorToTop(); // move view to top of page - - // prevent form submission - event.preventDefault(); - return false; } + + //tags invalid + enableUploadButton(); // enable Upload button + anchorToTop(); // move view to top of page + + // prevent form submission + event.preventDefault(); + return false; } fileField.addEventListener('change', registerBeforeUnload); From c361118472b86b852c06a0801d9d1b58a37f5004 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 17:26:59 -0400 Subject: [PATCH 079/115] linting, js test troubleshooting --- assets/js/__tests__/upload.spec.ts | 16 ++++++++++++++++ assets/js/upload.js | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index 06d14d64..4647fbb4 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -58,6 +58,8 @@ describe('Image upload form', () => { let scraperError: HTMLDivElement; let fetchButton: HTMLButtonElement; let tagsEl: HTMLTextAreaElement; + let tagsinputEl: HTMLDivElement; + let tagEl: HTMLSpanElement; let sourceEl: HTMLInputElement; let descrEl: HTMLTextAreaElement; @@ -77,6 +79,18 @@ describe('Image upload form', () => { +
+ + "safe x" + + + "pony x" + + + "tag3 x" + +
+
`, ); @@ -101,11 +94,11 @@ describe('Image upload form', () => { remoteUrl = assertNotUndefined($$('.js-scraper')[1]); scraperError = assertNotUndefined($$('.js-scraper')[2]); tagsEl = assertNotNull($('.js-image-tags-input')); - tagsinputEl = assertNotNull($('.js-taginput')); - tagEl = assertNotNull($('.tag')); // ensure at least one exists + taginputEl = assertNotNull($('.js-taginput')); sourceEl = assertNotNull($('.js-source-url')); descrEl = assertNotNull($('.js-image-descr-input')); fetchButton = assertNotNull($('#js-scraper-preview')); + submitButton = assertNotNull($('.actions > .button')) setupImageUpload(); fetchMock.resetMocks(); diff --git a/assets/js/upload.js b/assets/js/upload.js index b9d2b3fa..3380e4bd 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -176,40 +176,26 @@ function setupImageUpload() { const errorElement = makeEl('span', { className: 'help-block tag-error' }); errorElement.innerText = message; - buttonAfter.parentElement.insertBefore(errorElement, buttonAfter); + buttonAfter.insertAdjacentElement('beforebegin', errorElement); } function clearTagErrors() { - const tagErrorElements = $$('.tag-error'); - - // remove() causes the length to decrease - while (tagErrorElements.length > 0) { - tagErrorElements[0].remove(); - } + $$('.tag-error').forEach(el => el.remove()); } // populate tag error helper bars as necessary // return true if all checks pass // return false if any check fails function validateTags() { - const tags = $$('.tag'); + const tagInput = $('textarea.js-taginput'); - if (tags.length === 0) { - createTagError('Tag input must contain at least 3 tags'); - return false; + if (!tagInput) { + return true; } - const tagsArr = []; - - for (const i in tags) { - let tag = tags[i].innerText; - - tag = tag.substring(0, tag.length - 2); // remove " x" from the end - tagsArr.push(tag); - } + const tagsArr = tagInput.value.split(',').map(t => t.trim()); const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque']; - const errors = []; let hasRating = false; From 583856d6e867551bb9cbd2b86e732d7109e32112 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 28 Aug 2024 02:36:33 +0400 Subject: [PATCH 081/115] Converting autocomplete to TypeScript --- .../js/{autocomplete.js => autocomplete.ts} | 204 ++++++++++-------- 1 file changed, 120 insertions(+), 84 deletions(-) rename assets/js/{autocomplete.js => autocomplete.ts} (52%) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.ts similarity index 52% rename from assets/js/autocomplete.js rename to assets/js/autocomplete.ts index 8ec0f278..19bdfff0 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.ts @@ -6,52 +6,67 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; +import { TermContext } from './query/lex.ts'; +import { $, $$, makeEl, removeEl } from './utils/dom.ts'; -const cache = {}; -/** @type {HTMLInputElement} */ -let inputField, - /** @type {string} */ - originalTerm, - /** @type {string} */ - originalQuery, - /** @type {TermContext} */ - selectedTerm; +type TermSuggestion = { + label: string; + value: string; +}; + +const cachedSuggestions: Record = {}; +let inputField: HTMLInputElement | null = null, + originalTerm: string | undefined, + originalQuery: string | undefined, + selectedTerm: TermContext | null = null; function removeParent() { - const parent = document.querySelector('.autocomplete'); - if (parent) parent.parentNode.removeChild(parent); + const parent = $('.autocomplete'); + if (parent) removeEl(parent); } function removeSelected() { - const selected = document.querySelector('.autocomplete__item--selected'); + const selected = $('.autocomplete__item--selected'); if (selected) selected.classList.remove('autocomplete__item--selected'); } -function isSearchField() { - return inputField && inputField.dataset.acMode === 'search'; +function isSearchField(targetInput: HTMLElement) { + return targetInput && targetInput.dataset.acMode === 'search'; } function restoreOriginalValue() { - inputField.value = isSearchField() ? originalQuery : originalTerm; + if (!inputField) { + return; + } + + if (isSearchField(inputField) && originalQuery) { + inputField.value = originalQuery; + } + + if (originalTerm) { + inputField.value = originalTerm; + } } -function applySelectedValue(selection) { - if (!isSearchField()) { +function applySelectedValue(selection: string) { + if (!inputField) { + return; + } + + if (!isSearchField(inputField)) { inputField.value = selection; return; } - if (!selectedTerm) { - return; + if (selectedTerm && originalQuery) { + const [startIndex, endIndex] = selectedTerm[0]; + inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex); + inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length); + inputField.focus(); } - - const [startIndex, endIndex] = selectedTerm[0]; - inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex); - inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length); - inputField.focus(); } -function changeSelected(firstOrLast, current, sibling) { +function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) { if (current && sibling) { // if the currently selected item has a sibling, move selection to it current.classList.remove('autocomplete__item--selected'); @@ -67,18 +82,21 @@ function changeSelected(firstOrLast, current, sibling) { } function isSelectionOutsideCurrentTerm() { + if (!inputField || !selectedTerm) return true; + if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; + const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); const [startIndex, endIndex] = selectedTerm[0]; return startIndex > selectionIndex || endIndex < selectionIndex; } -function keydownHandler(event) { - const selected = document.querySelector('.autocomplete__item--selected'), - firstItem = document.querySelector('.autocomplete__item:first-of-type'), - lastItem = document.querySelector('.autocomplete__item:last-of-type'); +function keydownHandler(event: KeyboardEvent) { + const selected = $('.autocomplete__item--selected'), + firstItem = $('.autocomplete__item:first-of-type'), + lastItem = $('.autocomplete__item:last-of-type'); - if (isSearchField()) { + if (inputField && isSearchField(inputField)) { // Prevent submission of the search field when Enter was hit if (selected && event.keyCode === 13) event.preventDefault(); // Enter @@ -91,20 +109,21 @@ function keydownHandler(event) { } } - if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp - if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown + if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp + if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown - const newSelected = document.querySelector('.autocomplete__item--selected'); - if (newSelected) applySelectedValue(newSelected.dataset.value); + const newSelected = $('.autocomplete__item--selected'); + if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); event.preventDefault(); } } -function createItem(list, suggestion) { - const item = document.createElement('li'); - item.className = 'autocomplete__item'; +function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { + const item = makeEl('li', { + className: 'autocomplete__item', + }); item.textContent = suggestion.label; item.dataset.value = suggestion.value; @@ -119,7 +138,10 @@ function createItem(list, suggestion) { }); item.addEventListener('click', () => { + if (!inputField || !item.dataset.value) return; + applySelectedValue(item.dataset.value); + inputField.dispatchEvent( new CustomEvent('autocomplete', { detail: { @@ -134,66 +156,71 @@ function createItem(list, suggestion) { list.appendChild(item); } -function createList(suggestions) { - const parent = document.querySelector('.autocomplete'), - list = document.createElement('ul'); - list.className = 'autocomplete__list'; +function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) { + const list = makeEl('ul', { + className: 'autocomplete__list', + }); suggestions.forEach(suggestion => createItem(list, suggestion)); - parent.appendChild(list); + parentElement.appendChild(list); } -function createParent() { - const parent = document.createElement('div'); +function createParent(): HTMLElement { + const parent = makeEl('div'); parent.className = 'autocomplete'; - // Position the parent below the inputfield - parent.style.position = 'absolute'; - parent.style.left = `${inputField.offsetLeft}px`; - // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled - parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`; + if (inputField && inputField.parentElement) { + // Position the parent below the inputfield + parent.style.position = 'absolute'; + parent.style.left = `${inputField.offsetLeft}px`; + // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled + parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`; + } // We append the parent at the end of body document.body.appendChild(parent); + + return parent; } -function showAutocomplete(suggestions, fetchedTerm, targetInput) { +function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) { // Remove old autocomplete suggestions removeParent(); // Save suggestions in cache - cache[fetchedTerm] = suggestions; + cachedSuggestions[fetchedTerm] = suggestions; // If the input target is not empty, still visible, and suggestions were found if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) { - createParent(); - createList(suggestions); - inputField.addEventListener('keydown', keydownHandler); + createList(createParent(), suggestions); + targetInput.addEventListener('keydown', keydownHandler); } } -function getSuggestions(term) { +async function getSuggestions(term: string): Promise { // In case source URL was not given at all, do not try sending the request. - if (!inputField.dataset.acSource) return []; - return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); + if (!inputField?.dataset.acSource) return []; + + return await fetch(`${inputField.dataset.acSource}${term}`) + .then(handleError) + .then(response => response.json()); } -function getSelectedTerm() { - if (!inputField || !originalQuery) { - return null; - } +function getSelectedTerm(): TermContext | null { + if (!inputField || !originalQuery) return null; + if (inputField.selectionStart === null || inputField.selectionEnd === null) return null; const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); const terms = getTermContexts(originalQuery); - return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex); + return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; } function toggleSearchAutocomplete() { const enable = store.get('enable_search_ac'); - for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) { + for (const searchField of $$('input[data-ac-mode=search]')) { if (enable) { searchField.autocomplete = 'off'; } else { @@ -204,10 +231,9 @@ function toggleSearchAutocomplete() { } function listenAutocomplete() { - let timeout; + let timeout: number | undefined; - /** @type {LocalAutocompleter} */ - let localAc = null; + let localAc: LocalAutocompleter | null = null; let localFetched = false; document.addEventListener('focusin', fetchLocalAutocomplete); @@ -217,11 +243,15 @@ function listenAutocomplete() { fetchLocalAutocomplete(event); window.clearTimeout(timeout); - if (localAc !== null && 'ac' in event.target.dataset) { - inputField = event.target; + if (!(event.target instanceof HTMLInputElement)) return; + + const targetedInput = event.target; + + if (localAc !== null && 'ac' in targetedInput.dataset) { + inputField = targetedInput; let suggestionsCount = 5; - if (isSearchField()) { + if (isSearchField(inputField)) { originalQuery = inputField.value; selectedTerm = getSelectedTerm(); suggestionsCount = 10; @@ -242,29 +272,31 @@ function listenAutocomplete() { .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { - return showAutocomplete(suggestions, originalTerm, event.target); + return showAutocomplete(suggestions, originalTerm, targetedInput); } } // Use a timeout to delay requests until the user has stopped typing timeout = window.setTimeout(() => { - inputField = event.target; + inputField = targetedInput; originalTerm = inputField.value; const fetchedTerm = inputField.value; const { ac, acMinLength, acSource } = inputField.dataset; - if (ac && acSource && fetchedTerm.length >= acMinLength) { - if (cache[fetchedTerm]) { - showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target); - } else { - // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions(fetchedTerm).then(suggestions => { - if (fetchedTerm === event.target.value) { - showAutocomplete(suggestions, fetchedTerm, event.target); - } - }); - } + if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { + return; + } + + if (cachedSuggestions[fetchedTerm]) { + showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput); + } else { + // inputField could get overwritten while the suggestions are being fetched - use event.target + getSuggestions(fetchedTerm).then(suggestions => { + if (fetchedTerm === targetedInput.value) { + showAutocomplete(suggestions, fetchedTerm, targetedInput); + } + }); } }, 300); }); @@ -272,10 +304,14 @@ function listenAutocomplete() { // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { if (event.target && event.target !== inputField) removeParent(); - if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent(); + if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) { + removeParent(); + } }); - function fetchLocalAutocomplete(event) { + function fetchLocalAutocomplete(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) { const now = new Date(); const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; From 9b99b17cea5b04dc399478e8b9913cc229099c5f Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 18:38:14 -0400 Subject: [PATCH 082/115] linting, review changes --- assets/js/__tests__/upload.spec.ts | 10 ++++++++- assets/js/upload.js | 34 +++++++++++++++++------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index aa7a6ef5..549af81f 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -67,6 +67,14 @@ describe('Image upload form', () => { if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled'); }; + const assertSubmitButtonIsDisabled = () => { + if (!submitButton.hasAttribute('disabled')) throw new Error('submitButton is not disabled'); + }; + + const assertSubmitButtonIsEnabled = () => { + if (submitButton.hasAttribute('disabled')) throw new Error('submitButton is disabled'); + }; + beforeEach(() => { document.documentElement.insertAdjacentHTML( 'beforeend', @@ -98,7 +106,7 @@ describe('Image upload form', () => { sourceEl = assertNotNull($('.js-source-url')); descrEl = assertNotNull($('.js-image-descr-input')); fetchButton = assertNotNull($('#js-scraper-preview')); - submitButton = assertNotNull($('.actions > .button')) + submitButton = assertNotNull($('.actions > .button')); setupImageUpload(); fetchMock.resetMocks(); diff --git a/assets/js/upload.js b/assets/js/upload.js index 3380e4bd..c904379d 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -183,6 +183,8 @@ function setupImageUpload() { $$('.tag-error').forEach(el => el.remove()); } + const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque']; + // populate tag error helper bars as necessary // return true if all checks pass // return false if any check fails @@ -195,7 +197,6 @@ function setupImageUpload() { const tagsArr = tagInput.value.split(',').map(t => t.trim()); - const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque']; const errors = []; let hasRating = false; @@ -250,29 +251,32 @@ function setupImageUpload() { function anchorToTop() { let url = window.location.href; url = url.split('#')[0]; //remove any existing hash anchor from url - url += '#'; //move view to top of page + url += '#taginput-fancy-tag_input'; //move view to tags input window.location.href = url; } function submitHandler(event) { - clearTagErrors(); // remove any existing tag error elements + // Remove any existing tag error elements + clearTagErrors(); - if (validateTags() === true) { - // tags valid; + if (validateTags()) { + // Disable navigation check unregisterBeforeUnload(); - // allow form submission + // Prevent duplicate attempts to submit the form disableUploadButton(); - return true; + + // Let the form submission complete + } else { + // Scroll to the top of page to see validation errors + anchorToTop(); + + // allow users to re-submit the form + enableUploadButton(); + + // Prevent the form from being submitted + event.preventDefault(); } - - //tags invalid - enableUploadButton(); // enable Upload button - anchorToTop(); // move view to top of page - - // prevent form submission - event.preventDefault(); - return false; } fileField.addEventListener('change', registerBeforeUnload); From b7733a2ae5e559aa172254486c4ccc3c6c439772 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 18:52:11 -0400 Subject: [PATCH 083/115] scroll instead of anchor, closed button tags --- assets/js/__tests__/upload.spec.ts | 4 ++-- assets/js/upload.js | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index 549af81f..ec0b986a 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -88,10 +88,10 @@ describe('Image upload form', () => {
-
-
`, ); diff --git a/assets/js/upload.js b/assets/js/upload.js index c904379d..8153fad8 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -248,11 +248,15 @@ function setupImageUpload() { requestAnimationFrame(() => submitButton.setAttribute('disabled', 'disabled')); } - function anchorToTop() { - let url = window.location.href; - url = url.split('#')[0]; //remove any existing hash anchor from url - url += '#taginput-fancy-tag_input'; //move view to tags input - window.location.href = url; + function scrollToTags() { + const taginputEle = $('#taginput-fancy-tag_input'); + + if (!taginputEle) { + // default to scroll to top + window.scrollTo({ top: 0, left: 0 }); + } + + taginputEle.scrollIntoView(); } function submitHandler(event) { @@ -268,8 +272,8 @@ function setupImageUpload() { // Let the form submission complete } else { - // Scroll to the top of page to see validation errors - anchorToTop(); + // Scroll to view validation errors + scrollToTags(); // allow users to re-submit the form enableUploadButton(); From 76213ed16918c8a70c689217234aacfa9e86de11 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 28 Aug 2024 03:11:27 +0400 Subject: [PATCH 084/115] Added explicit types for several functions --- assets/js/autocomplete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 19bdfff0..21fd50dd 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -30,7 +30,7 @@ function removeSelected() { if (selected) selected.classList.remove('autocomplete__item--selected'); } -function isSearchField(targetInput: HTMLElement) { +function isSearchField(targetInput: HTMLElement): boolean { return targetInput && targetInput.dataset.acMode === 'search'; } @@ -81,7 +81,7 @@ function changeSelected(firstOrLast: Element | null, current: Element | null, si } } -function isSelectionOutsideCurrentTerm() { +function isSelectionOutsideCurrentTerm(): boolean { if (!inputField || !selectedTerm) return true; if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; From 204e48d05b241ff6cceedbff5cd9576d43385524 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 19:16:13 -0400 Subject: [PATCH 085/115] mocking out a test that actually compiles --- assets/js/__tests__/upload.spec.ts | 21 +++++++++++++++++++-- assets/js/upload.js | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index ec0b986a..233bcd83 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -87,11 +87,11 @@ describe('Image upload form', () => { -
+
- +
`, ); @@ -210,4 +210,21 @@ describe('Image upload form', () => { expect(scraperError.innerText).toEqual('Error 1 Error 2'); }); }); + + it('should prevent form submission if tag checks fail', async () => { + await new Promise(resolve => { + form.addEventListener('submit', event => { + event.preventDefault(); + resolve(); + }); + fireEvent.submit(form); + }); + + const succeededUnloadEvent = new Event('beforeunload', { cancelable: true }); + expect(fireEvent(window, succeededUnloadEvent)).toBe(true); + await waitFor(() => { + assertSubmitButtonIsEnabled(); + expect(form.querySelectorAll('.help-block')).toHaveLength(1); + }); + }); }); diff --git a/assets/js/upload.js b/assets/js/upload.js index 8153fad8..fe068899 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -238,7 +238,7 @@ function setupImageUpload() { } function disableUploadButton() { - const submitButton = $('.input--separate-top'); + const submitButton = $('.button.input--separate-top'); if (submitButton !== null) { submitButton.disabled = true; submitButton.innerText = 'Please wait...'; From b713524989276b134cd030d66454a840db0b5726 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 19:29:41 -0400 Subject: [PATCH 086/115] setting up test --- assets/js/__tests__/upload.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index 233bcd83..f58132f1 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -25,6 +25,8 @@ const errorResponse = { }; /* eslint-enable camelcase */ +const tagSets = ['safe', 'one, two, three', 'safe, expicit', 'safe, two, three']; + describe('Image upload form', () => { let mockPng: File; let mockWebm: File; @@ -87,7 +89,7 @@ describe('Image upload form', () => { -
+
@@ -212,6 +214,14 @@ describe('Image upload form', () => { }); it('should prevent form submission if tag checks fail', async () => { + tagSets.forEach(tags => { + taginputEl.value = tags; + //TODO fire submit event + // check whether the form fully submitted or was prevented by tag checks + // verify the number of error help blocks added + // check if the submit button is enabled/disabled + }); + await new Promise(resolve => { form.addEventListener('submit', event => { event.preventDefault(); From 4010a8a277b8a6523025c88b97f1331439491497 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Tue, 27 Aug 2024 20:02:23 -0400 Subject: [PATCH 087/115] tests for client side tag validation --- assets/js/__tests__/upload.spec.ts | 58 ++++++++++++++++++------------ 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index f58132f1..bd5d9c5e 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -25,7 +25,8 @@ const errorResponse = { }; /* eslint-enable camelcase */ -const tagSets = ['safe', 'one, two, three', 'safe, expicit', 'safe, two, three']; +const tagSets = ['', 'a tag', 'safe', 'one, two, three', 'safe, explicit', 'safe, explicit, three', 'safe, two, three']; +const tagErrorCounts = [1, 2, 1, 1, 2, 1, 0]; describe('Image upload form', () => { let mockPng: File; @@ -213,28 +214,41 @@ describe('Image upload form', () => { }); }); + async function submitForm(frm): Promise { + return new Promise(resolve => { + function onSubmit() { + frm.removeEventListener('submit', onSubmit); + resolve(true); + } + + frm.addEventListener('submit', onSubmit); + + if (!fireEvent.submit(frm)) { + frm.removeEventListener('submit', onSubmit); + resolve(false); + } + }); + } + it('should prevent form submission if tag checks fail', async () => { - tagSets.forEach(tags => { - taginputEl.value = tags; - //TODO fire submit event - // check whether the form fully submitted or was prevented by tag checks - // verify the number of error help blocks added - // check if the submit button is enabled/disabled - }); + for (let i = 0; i < tagSets.length; i += 1) { + taginputEl.value = tagSets[i]; - await new Promise(resolve => { - form.addEventListener('submit', event => { - event.preventDefault(); - resolve(); - }); - fireEvent.submit(form); - }); - - const succeededUnloadEvent = new Event('beforeunload', { cancelable: true }); - expect(fireEvent(window, succeededUnloadEvent)).toBe(true); - await waitFor(() => { - assertSubmitButtonIsEnabled(); - expect(form.querySelectorAll('.help-block')).toHaveLength(1); - }); + if (await submitForm(form)) { + // form submit succeeded + await waitFor(() => { + assertSubmitButtonIsDisabled(); + const succeededUnloadEvent = new Event('beforeunload', { cancelable: true }); + expect(fireEvent(window, succeededUnloadEvent)).toBe(true); + }); + } else { + // form submit prevented + frm = form; + await waitFor(() => { + assertSubmitButtonIsEnabled(); + expect(frm.querySelectorAll('.help-block')).toHaveLength(tagErrorCounts[i]); + }); + } + } }); }); From fee1c3e65652050dfbbeff7fb3a088d22ba21c0d Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 29 Aug 2024 01:52:01 +0400 Subject: [PATCH 088/115] Convert markdown toolbar logic to TypeScript Additionally, these changes contain bugfix for the "Escape" button throwing an error if nothing is selected. --- ...{markdowntoolbar.js => markdowntoolbar.ts} | 108 +++++++++++++----- 1 file changed, 82 insertions(+), 26 deletions(-) rename assets/js/{markdowntoolbar.js => markdowntoolbar.ts} (69%) diff --git a/assets/js/markdowntoolbar.js b/assets/js/markdowntoolbar.ts similarity index 69% rename from assets/js/markdowntoolbar.js rename to assets/js/markdowntoolbar.ts index 05c8eb8e..cc39c0bc 100644 --- a/assets/js/markdowntoolbar.js +++ b/assets/js/markdowntoolbar.ts @@ -4,7 +4,25 @@ import { $, $$ } from './utils/dom'; -const markdownSyntax = { +// List of options provided to the syntax handler function. +type SyntaxHandlerOptions = { + prefix: string; + shortcutKeyCode: number; + suffix: string; + prefixMultiline: string; + suffixMultiline: string; + singleWrap: boolean; + escapeChar: string; + image: boolean; + text: string; +}; + +type SyntaxHandler = { + action: (textarea: HTMLTextAreaElement, options: Partial) => void; + options: Partial; +}; + +const markdownSyntax: Record = { bold: { action: wrapSelection, options: { prefix: '**', shortcutKeyCode: 66 }, @@ -62,14 +80,22 @@ const markdownSyntax = { }, }; -function getSelections(textarea, linesOnly = false) { +type SelectionResult = { + processLinesOnly: boolean; + selectedText: string; + beforeSelection: string; + afterSelection: string; +}; + +function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolean = false): SelectionResult { let { selectionStart, selectionEnd } = textarea, selection = textarea.value.substring(selectionStart, selectionEnd), leadingSpace = '', trailingSpace = '', - caret; + caret: number; const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly; + if (processLinesOnly) { const explorer = /\n/g; let startNewlineIndex = 0, @@ -119,7 +145,18 @@ function getSelections(textarea, linesOnly = false) { }; } -function transformSelection(textarea, transformer, eachLine) { +type TransformResult = { + newText: string; + caretOffset: number; +}; + +type TransformCallback = (selectedText: string, processLinesOnly: boolean) => TransformResult; + +function transformSelection( + textarea: HTMLTextAreaElement, + transformer: TransformCallback, + eachLine: RegExp | boolean = false, +) { const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine), // For long comments, record scrollbar position to restore it later { scrollTop } = textarea; @@ -140,7 +177,7 @@ function transformSelection(textarea, transformer, eachLine) { textarea.dispatchEvent(new Event('change')); } -function insertLink(textarea, options) { +function insertLink(textarea: HTMLTextAreaElement, options: Partial) { let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:'); if (!hyperlink || hyperlink === '') return; @@ -155,10 +192,11 @@ function insertLink(textarea, options) { wrapSelection(textarea, { prefix, suffix }); } -function wrapSelection(textarea, options) { - transformSelection(textarea, selectedText => { +function wrapSelection(textarea: HTMLTextAreaElement, options: Partial) { + transformSelection(textarea, (selectedText: string): TransformResult => { const { text = selectedText, prefix = '', suffix = options.prefix } = options, emptyText = text === ''; + let newText = text; if (!emptyText) { @@ -176,10 +214,14 @@ function wrapSelection(textarea, options) { }); } -function wrapLines(textarea, options, eachLine = true) { +function wrapLines( + textarea: HTMLTextAreaElement, + options: Partial, + eachLine: RegExp | boolean = true, +) { transformSelection( textarea, - (selectedText, processLinesOnly) => { + (selectedText: string, processLinesOnly: boolean): TransformResult => { const { text = selectedText, singleWrap = false } = options, prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', @@ -200,16 +242,22 @@ function wrapLines(textarea, options, eachLine = true) { ); } -function wrapSelectionOrLines(textarea, options) { +function wrapSelectionOrLines(textarea: HTMLTextAreaElement, options: Partial) { wrapLines(textarea, options, /\n/); } -function escapeSelection(textarea, options) { - transformSelection(textarea, selectedText => { +function escapeSelection(textarea: HTMLTextAreaElement, options: Partial) { + transformSelection(textarea, (selectedText: string): TransformResult => { const { text = selectedText } = options, emptyText = text === ''; - if (emptyText) return; + // Even if there is nothing to escape, we still need to return the result, otherwise the error would be thrown. + if (emptyText) { + return { + newText: text, + caretOffset: text.length, + }; + } const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1'); @@ -220,22 +268,28 @@ function escapeSelection(textarea, options) { }); } -function clickHandler(event) { - const button = event.target.closest('.communication__toolbar__button'); - if (!button) return; - const toolbar = button.closest('.communication__toolbar'), - // There may be multiple toolbars present on the page, - // in the case of image pages with description edit active - // we target the textarea that shares the same parent as the toolbar - textarea = $('.js-toolbar-input', toolbar.parentNode), +function clickHandler(event: MouseEvent) { + if (!(event.target instanceof HTMLElement)) return; + + const button = event.target?.closest('.communication__toolbar__button'); + const toolbar = button?.closest('.communication__toolbar'); + + if (!button || !toolbar?.parentElement) return; + + // There may be multiple toolbars present on the page, + // in the case of image pages with description edit active + // we target the textarea that shares the same parent as the toolbar + const textarea = $('.js-toolbar-input', toolbar.parentElement), id = button.dataset.syntaxId; + if (!textarea || !id) return; + markdownSyntax[id].action(textarea, markdownSyntax[id].options); textarea.focus(); } -function canAcceptShortcut(event) { - let ctrl, otherModifier; +function canAcceptShortcut(event: KeyboardEvent): boolean { + let ctrl: boolean, otherModifier: boolean; switch (window.navigator.platform) { case 'MacIntel': @@ -251,7 +305,7 @@ function canAcceptShortcut(event) { return ctrl && !otherModifier; } -function shortcutHandler(event) { +function shortcutHandler(event: KeyboardEvent) { if (!canAcceptShortcut(event)) { return; } @@ -259,6 +313,8 @@ function shortcutHandler(event) { const textarea = event.target, keyCode = event.keyCode; + if (!(textarea instanceof HTMLTextAreaElement)) return; + for (const id in markdownSyntax) { if (keyCode === markdownSyntax[id].options.shortcutKeyCode) { markdownSyntax[id].action(textarea, markdownSyntax[id].options); @@ -268,10 +324,10 @@ function shortcutHandler(event) { } function setupToolbar() { - $$('.communication__toolbar').forEach(toolbar => { + $$('.communication__toolbar').forEach(toolbar => { toolbar.addEventListener('click', clickHandler); }); - $$('.js-toolbar-input').forEach(textarea => { + $$('.js-toolbar-input').forEach(textarea => { textarea.addEventListener('keydown', shortcutHandler); }); } From 2c60f9d6625110f13be6cd423c689bdec1654d0b Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 28 Aug 2024 18:10:19 -0400 Subject: [PATCH 089/115] Add missing interactions to reverse page --- lib/philomena_web/controllers/search/reverse_controller.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex index ef4fa836..0938642a 100644 --- a/lib/philomena_web/controllers/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/search/reverse_controller.ex @@ -3,6 +3,7 @@ defmodule PhilomenaWeb.Search.ReverseController do alias Philomena.DuplicateReports.SearchQuery alias Philomena.DuplicateReports + alias Philomena.Interactions plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image" @@ -16,12 +17,14 @@ defmodule PhilomenaWeb.Search.ReverseController do case DuplicateReports.execute_search_query(image_params) do {:ok, images} -> changeset = DuplicateReports.change_search_query(%SearchQuery{}) + interactions = Interactions.user_interactions(images, conn.assigns.current_user) render(conn, "index.html", title: "Reverse Search", layout_class: "layout--wide", images: images, - changeset: changeset + changeset: changeset, + interactions: interactions ) {:error, changeset} -> From 7643f5ddb2a7759b2bc514161d00726754bbc9cf Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 29 Aug 2024 02:19:27 +0400 Subject: [PATCH 090/115] Converting types to interfaces --- assets/js/markdowntoolbar.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/assets/js/markdowntoolbar.ts b/assets/js/markdowntoolbar.ts index cc39c0bc..f09b586c 100644 --- a/assets/js/markdowntoolbar.ts +++ b/assets/js/markdowntoolbar.ts @@ -5,7 +5,7 @@ import { $, $$ } from './utils/dom'; // List of options provided to the syntax handler function. -type SyntaxHandlerOptions = { +interface SyntaxHandlerOptions { prefix: string; shortcutKeyCode: number; suffix: string; @@ -15,12 +15,12 @@ type SyntaxHandlerOptions = { escapeChar: string; image: boolean; text: string; -}; +} -type SyntaxHandler = { +interface SyntaxHandler { action: (textarea: HTMLTextAreaElement, options: Partial) => void; options: Partial; -}; +} const markdownSyntax: Record = { bold: { @@ -80,12 +80,12 @@ const markdownSyntax: Record = { }, }; -type SelectionResult = { +interface SelectionResult { processLinesOnly: boolean; selectedText: string; beforeSelection: string; afterSelection: string; -}; +} function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolean = false): SelectionResult { let { selectionStart, selectionEnd } = textarea, @@ -145,10 +145,10 @@ function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolea }; } -type TransformResult = { +interface TransformResult { newText: string; caretOffset: number; -}; +} type TransformCallback = (selectedText: string, processLinesOnly: boolean) => TransformResult; From aa186fa0ad8d60f0349f0701a4bd08a1dfcfeb12 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 29 Aug 2024 02:20:22 +0400 Subject: [PATCH 091/115] Simplifying comment --- assets/js/markdowntoolbar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/markdowntoolbar.ts b/assets/js/markdowntoolbar.ts index f09b586c..f8d23008 100644 --- a/assets/js/markdowntoolbar.ts +++ b/assets/js/markdowntoolbar.ts @@ -251,7 +251,7 @@ function escapeSelection(textarea: HTMLTextAreaElement, options: Partial Date: Thu, 29 Aug 2024 02:21:14 +0400 Subject: [PATCH 092/115] Removing unnecessary nullish operator --- assets/js/markdowntoolbar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/markdowntoolbar.ts b/assets/js/markdowntoolbar.ts index f8d23008..f9ceb840 100644 --- a/assets/js/markdowntoolbar.ts +++ b/assets/js/markdowntoolbar.ts @@ -271,7 +271,7 @@ function escapeSelection(textarea: HTMLTextAreaElement, options: Partial('.communication__toolbar__button'); + const button = event.target.closest('.communication__toolbar__button'); const toolbar = button?.closest('.communication__toolbar'); if (!button || !toolbar?.parentElement) return; From c8bd0c9c33f3b667fb2b1108c27b13b5a86cef5b Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 29 Aug 2024 03:31:43 +0400 Subject: [PATCH 093/115] Require mouse movement before autocomplete options are selected on hover --- assets/js/autocomplete.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 21fd50dd..26797685 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -125,10 +125,17 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { className: 'autocomplete__item', }); + let ignoreMouseOver = true; + item.textContent = suggestion.label; item.dataset.value = suggestion.value; item.addEventListener('mouseover', () => { + // Prevent selection when mouse entered the element without actually moving. + if (ignoreMouseOver) { + return; + } + removeSelected(); item.classList.add('autocomplete__item--selected'); }); @@ -137,6 +144,17 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { removeSelected(); }); + item.addEventListener( + 'mousemove', + () => { + ignoreMouseOver = false; + item.dispatchEvent(new CustomEvent('mouseover')); + }, + { + once: true, + }, + ); + item.addEventListener('click', () => { if (!inputField || !item.dataset.value) return; From 53d345ddffd856febd2c1e5534662e777c1802af Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 29 Aug 2024 21:07:42 +0400 Subject: [PATCH 094/115] Moving `mouseover` handler into function for calling it from `mousemove` --- assets/js/autocomplete.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 26797685..2fecad0d 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -130,7 +130,7 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { item.textContent = suggestion.label; item.dataset.value = suggestion.value; - item.addEventListener('mouseover', () => { + function onItemMouseOver() { // Prevent selection when mouse entered the element without actually moving. if (ignoreMouseOver) { return; @@ -138,7 +138,9 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { removeSelected(); item.classList.add('autocomplete__item--selected'); - }); + } + + item.addEventListener('mouseover', onItemMouseOver); item.addEventListener('mouseout', () => { removeSelected(); @@ -148,7 +150,7 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { 'mousemove', () => { ignoreMouseOver = false; - item.dispatchEvent(new CustomEvent('mouseover')); + onItemMouseOver(); }, { once: true, From a3152fc9e0bd0d0222fdacd5c2ce72211e39e1c1 Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 29 Aug 2024 13:55:17 -0400 Subject: [PATCH 095/115] Add mouseMoveThenOver, test --- assets/js/autocomplete.ts | 25 ++---------- assets/js/utils/__tests__/events.spec.ts | 51 +++++++++++++++++++++++- assets/js/utils/events.ts | 11 +++++ 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 2fecad0d..033176c8 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -8,6 +8,7 @@ import { getTermContexts } from './match_query'; import store from './utils/store'; import { TermContext } from './query/lex.ts'; import { $, $$, makeEl, removeEl } from './utils/dom.ts'; +import { mouseMoveThenOver } from './utils/events.ts'; type TermSuggestion = { label: string; @@ -125,38 +126,18 @@ function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { className: 'autocomplete__item', }); - let ignoreMouseOver = true; - item.textContent = suggestion.label; item.dataset.value = suggestion.value; - function onItemMouseOver() { - // Prevent selection when mouse entered the element without actually moving. - if (ignoreMouseOver) { - return; - } - + mouseMoveThenOver(item, () => { removeSelected(); item.classList.add('autocomplete__item--selected'); - } - - item.addEventListener('mouseover', onItemMouseOver); + }); item.addEventListener('mouseout', () => { removeSelected(); }); - item.addEventListener( - 'mousemove', - () => { - ignoreMouseOver = false; - onItemMouseOver(); - }, - { - once: true, - }, - ); - item.addEventListener('click', () => { if (!inputField || !item.dataset.value) return; diff --git a/assets/js/utils/__tests__/events.spec.ts b/assets/js/utils/__tests__/events.spec.ts index 575883b7..ab1dbd67 100644 --- a/assets/js/utils/__tests__/events.spec.ts +++ b/assets/js/utils/__tests__/events.spec.ts @@ -1,4 +1,4 @@ -import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap } from '../events'; +import { delegate, fire, mouseMoveThenOver, leftClick, on, PhilomenaAvailableEventsMap } from '../events'; import { getRandomArrayItem } from '../../../test/randomness'; import { fireEvent } from '@testing-library/dom'; @@ -80,6 +80,55 @@ describe('Event utils', () => { }); }); + describe('mouseMoveThenOver', () => { + it('should NOT fire on first mouseover', () => { + const mockButton = document.createElement('button'); + const mockHandler = vi.fn(); + + mouseMoveThenOver(mockButton, mockHandler); + + fireEvent.mouseOver(mockButton); + + expect(mockHandler).toHaveBeenCalledTimes(0); + }); + + it('should fire on the first mousemove', () => { + const mockButton = document.createElement('button'); + const mockHandler = vi.fn(); + + mouseMoveThenOver(mockButton, mockHandler); + + fireEvent.mouseMove(mockButton); + + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it('should fire on subsequent mouseover', () => { + const mockButton = document.createElement('button'); + const mockHandler = vi.fn(); + + mouseMoveThenOver(mockButton, mockHandler); + + fireEvent.mouseMove(mockButton); + fireEvent.mouseOver(mockButton); + + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + + it('should NOT fire on subsequent mousemove', () => { + const mockButton = document.createElement('button'); + const mockHandler = vi.fn(); + + mouseMoveThenOver(mockButton, mockHandler); + + fireEvent.mouseMove(mockButton); + fireEvent.mouseOver(mockButton); + fireEvent.mouseMove(mockButton); + + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + }); + describe('delegate', () => { it('should call the native addEventListener method on the element', () => { const mockElement = document.createElement('div'); diff --git a/assets/js/utils/events.ts b/assets/js/utils/events.ts index 70460bf8..458df039 100644 --- a/assets/js/utils/events.ts +++ b/assets/js/utils/events.ts @@ -43,6 +43,17 @@ export function leftClick(func }; } +export function mouseMoveThenOver(element: El, func: (e: MouseEvent) => void) { + element.addEventListener( + 'mousemove', + (event: MouseEvent) => { + func(event); + element.addEventListener('mouseover', func); + }, + { once: true }, + ); +} + export function delegate( node: PhilomenaEventElement, event: K, From 343078678a1747d2a6d0d031395017bc169798f3 Mon Sep 17 00:00:00 2001 From: wrenny-ko Date: Thu, 29 Aug 2024 18:35:20 -0400 Subject: [PATCH 096/115] scroll to tag block, review suggestions, cleanup --- assets/js/upload.js | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/assets/js/upload.js b/assets/js/upload.js index fe068899..0f931037 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -2,6 +2,7 @@ * Fetch and display preview images for various image upload forms. */ +import { assertNotNull } from './utils/assert'; import { fetchJson, handleError } from './utils/requests'; import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom'; import { addTag } from './tagsinput'; @@ -173,9 +174,8 @@ function setupImageUpload() { function createTagError(message) { const buttonAfter = $('#tagsinput-save'); - const errorElement = makeEl('span', { className: 'help-block tag-error' }); + const errorElement = makeEl('span', { className: 'help-block tag-error', innerText: message }); - errorElement.innerText = message; buttonAfter.insertAdjacentElement('beforebegin', errorElement); } @@ -229,14 +229,6 @@ function setupImageUpload() { return errors.length === 0; // true: valid if no errors } - function enableUploadButton() { - const submitButton = $('.input--separate-top'); - if (submitButton !== null) { - submitButton.disabled = false; - submitButton.innerText = 'Upload'; - } - } - function disableUploadButton() { const submitButton = $('.button.input--separate-top'); if (submitButton !== null) { @@ -248,17 +240,6 @@ function setupImageUpload() { requestAnimationFrame(() => submitButton.setAttribute('disabled', 'disabled')); } - function scrollToTags() { - const taginputEle = $('#taginput-fancy-tag_input'); - - if (!taginputEle) { - // default to scroll to top - window.scrollTo({ top: 0, left: 0 }); - } - - taginputEle.scrollIntoView(); - } - function submitHandler(event) { // Remove any existing tag error elements clearTagErrors(); @@ -273,10 +254,7 @@ function setupImageUpload() { // Let the form submission complete } else { // Scroll to view validation errors - scrollToTags(); - - // allow users to re-submit the form - enableUploadButton(); + assertNotNull($('.fancy-tag-upload')).scrollIntoView(); // Prevent the form from being submitted event.preventDefault(); From 0fe6cd78429ce1e400819b776b49959d5a7f97ec Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 18:48:30 +0400 Subject: [PATCH 097/115] Extracting chunks of code & slightly refactoring autocomplete script --- assets/js/autocomplete.ts | 233 ++++++++++----------------------- assets/js/utils/suggestions.ts | 165 +++++++++++++++++++++++ 2 files changed, 233 insertions(+), 165 deletions(-) create mode 100644 assets/js/utils/suggestions.ts diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 033176c8..4a769ef3 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -6,39 +6,23 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; -import { TermContext } from './query/lex.ts'; -import { $, $$, makeEl, removeEl } from './utils/dom.ts'; -import { mouseMoveThenOver } from './utils/events.ts'; +import { TermContext } from './query/lex'; +import { $$ } from './utils/dom'; +import { fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; -type TermSuggestion = { - label: string; - value: string; -}; - -const cachedSuggestions: Record = {}; let inputField: HTMLInputElement | null = null, originalTerm: string | undefined, originalQuery: string | undefined, selectedTerm: TermContext | null = null; -function removeParent() { - const parent = $('.autocomplete'); - if (parent) removeEl(parent); -} - -function removeSelected() { - const selected = $('.autocomplete__item--selected'); - if (selected) selected.classList.remove('autocomplete__item--selected'); -} +const popup = new SuggestionsPopup(); function isSearchField(targetInput: HTMLElement): boolean { return targetInput && targetInput.dataset.acMode === 'search'; } function restoreOriginalValue() { - if (!inputField) { - return; - } + if (!inputField) return; if (isSearchField(inputField) && originalQuery) { inputField.value = originalQuery; @@ -50,9 +34,7 @@ function restoreOriginalValue() { } function applySelectedValue(selection: string) { - if (!inputField) { - return; - } + if (!inputField) return; if (!isSearchField(inputField)) { inputField.value = selection; @@ -67,21 +49,6 @@ function applySelectedValue(selection: string) { } } -function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) { - if (current && sibling) { - // if the currently selected item has a sibling, move selection to it - current.classList.remove('autocomplete__item--selected'); - sibling.classList.add('autocomplete__item--selected'); - } else if (current) { - // if the next keypress will take the user outside the list, restore the unautocompleted term - restoreOriginalValue(); - removeSelected(); - } else if (firstOrLast) { - // if no item in the list is selected, select the first or last - firstOrLast.classList.add('autocomplete__item--selected'); - } -} - function isSelectionOutsideCurrentTerm(): boolean { if (!inputField || !selectedTerm) return true; if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; @@ -93,127 +60,43 @@ function isSelectionOutsideCurrentTerm(): boolean { } function keydownHandler(event: KeyboardEvent) { - const selected = $('.autocomplete__item--selected'), - firstItem = $('.autocomplete__item:first-of-type'), - lastItem = $('.autocomplete__item:last-of-type'); + if (inputField !== event.currentTarget) return; if (inputField && isSearchField(inputField)) { // Prevent submission of the search field when Enter was hit - if (selected && event.keyCode === 13) event.preventDefault(); // Enter + if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter // Close autocompletion popup when text cursor is outside current tag - if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { + if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight requestAnimationFrame(() => { - if (isSelectionOutsideCurrentTerm()) removeParent(); + if (isSelectionOutsideCurrentTerm()) popup.hide(); }); } } - if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp - if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown - if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma + if (!popup.isActive) return; + + if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp + if (event.keyCode === 40) popup.selectNext(); // ArrowDown + if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown - const newSelected = $('.autocomplete__item--selected'); - if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); + if (popup.selectedTerm) { + applySelectedValue(popup.selectedTerm); + } else { + restoreOriginalValue(); + } + event.preventDefault(); } } -function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { - const item = makeEl('li', { - className: 'autocomplete__item', - }); +function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null { + if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null; - item.textContent = suggestion.label; - item.dataset.value = suggestion.value; - - mouseMoveThenOver(item, () => { - removeSelected(); - item.classList.add('autocomplete__item--selected'); - }); - - item.addEventListener('mouseout', () => { - removeSelected(); - }); - - item.addEventListener('click', () => { - if (!inputField || !item.dataset.value) return; - - applySelectedValue(item.dataset.value); - - inputField.dispatchEvent( - new CustomEvent('autocomplete', { - detail: { - type: 'click', - label: suggestion.label, - value: suggestion.value, - }, - }), - ); - }); - - list.appendChild(item); -} - -function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) { - const list = makeEl('ul', { - className: 'autocomplete__list', - }); - - suggestions.forEach(suggestion => createItem(list, suggestion)); - - parentElement.appendChild(list); -} - -function createParent(): HTMLElement { - const parent = makeEl('div'); - parent.className = 'autocomplete'; - - if (inputField && inputField.parentElement) { - // Position the parent below the inputfield - parent.style.position = 'absolute'; - parent.style.left = `${inputField.offsetLeft}px`; - // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled - parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`; - } - - // We append the parent at the end of body - document.body.appendChild(parent); - - return parent; -} - -function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) { - // Remove old autocomplete suggestions - removeParent(); - - // Save suggestions in cache - cachedSuggestions[fetchedTerm] = suggestions; - - // If the input target is not empty, still visible, and suggestions were found - if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) { - createList(createParent(), suggestions); - targetInput.addEventListener('keydown', keydownHandler); - } -} - -async function getSuggestions(term: string): Promise { - // In case source URL was not given at all, do not try sending the request. - if (!inputField?.dataset.acSource) return []; - - return await fetch(`${inputField.dataset.acSource}${term}`) - .then(handleError) - .then(response => response.json()); -} - -function getSelectedTerm(): TermContext | null { - if (!inputField || !originalQuery) return null; - if (inputField.selectionStart === null || inputField.selectionEnd === null) return null; - - const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); - const terms = getTermContexts(originalQuery); + const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); + const terms = getTermContexts(searchQuery); return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; } @@ -232,7 +115,7 @@ function toggleSearchAutocomplete() { } function listenAutocomplete() { - let timeout: number | undefined; + let serverSideSuggestionsTimeout: number | undefined; let localAc: LocalAutocompleter | null = null; let localFetched = false; @@ -240,21 +123,25 @@ function listenAutocomplete() { document.addEventListener('focusin', fetchLocalAutocomplete); document.addEventListener('input', event => { - removeParent(); + popup.hide(); fetchLocalAutocomplete(event); - window.clearTimeout(timeout); + window.clearTimeout(serverSideSuggestionsTimeout); if (!(event.target instanceof HTMLInputElement)) return; const targetedInput = event.target; - if (localAc !== null && 'ac' in targetedInput.dataset) { + if (!targetedInput.dataset.ac) return; + + targetedInput.addEventListener('keydown', keydownHandler); + + if (localAc !== null) { inputField = targetedInput; let suggestionsCount = 5; if (isSearchField(inputField)) { originalQuery = inputField.value; - selectedTerm = getSelectedTerm(); + selectedTerm = findSelectedTerm(inputField, originalQuery); suggestionsCount = 10; // We don't need to run auto-completion if user is not selecting tag at all @@ -273,40 +160,38 @@ function listenAutocomplete() { .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { - return showAutocomplete(suggestions, originalTerm, targetedInput); + popup.renderSuggestions(suggestions).showForField(targetedInput); + return; } } + const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset; + + if (!endpointUrl) return; + // Use a timeout to delay requests until the user has stopped typing - timeout = window.setTimeout(() => { + serverSideSuggestionsTimeout = window.setTimeout(() => { inputField = targetedInput; originalTerm = inputField.value; const fetchedTerm = inputField.value; - const { ac, acMinLength, acSource } = inputField.dataset; - if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { - return; - } + if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return; - if (cachedSuggestions[fetchedTerm]) { - showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput); - } else { - // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions(fetchedTerm).then(suggestions => { - if (fetchedTerm === targetedInput.value) { - showAutocomplete(suggestions, fetchedTerm, targetedInput); - } - }); - } + fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => { + // inputField could get overwritten while the suggestions are being fetched - use previously targeted input + if (fetchedTerm === targetedInput.value) { + popup.renderSuggestions(suggestions).showForField(targetedInput); + } + }); }, 300); }); // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { - if (event.target && event.target !== inputField) removeParent(); + if (event.target && event.target !== inputField) popup.hide(); if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) { - removeParent(); + popup.hide(); } }); @@ -329,6 +214,24 @@ function listenAutocomplete() { } toggleSearchAutocomplete(); + + popup.onItemSelected((event: CustomEvent) => { + if (!event.detail || !inputField) return; + + const originalSuggestion = event.detail; + applySelectedValue(originalSuggestion.value); + + inputField.dispatchEvent( + new CustomEvent('autocomplete', { + detail: Object.assign( + { + type: 'click', + }, + originalSuggestion, + ), + }), + ); + }); } export { listenAutocomplete }; diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts new file mode 100644 index 00000000..7f4e9637 --- /dev/null +++ b/assets/js/utils/suggestions.ts @@ -0,0 +1,165 @@ +import { makeEl } from './dom.ts'; +import { mouseMoveThenOver } from './events.ts'; +import { handleError } from './requests.ts'; + +export interface TermSuggestion { + label: string; + value: string; +} + +const selectedSuggestionClassName = 'autocomplete__item--selected'; + +export class SuggestionsPopup { + private readonly container: HTMLElement; + private readonly listElement: HTMLUListElement; + private selectedElement: HTMLElement | null = null; + + constructor() { + this.container = makeEl('div', { + className: 'autocomplete', + }); + + this.listElement = makeEl('ul', { + className: 'autocomplete__list', + }); + + this.container.appendChild(this.listElement); + } + + get selectedTerm(): string | null { + return this.selectedElement?.dataset.value || null; + } + + get isActive(): boolean { + return this.container.isConnected; + } + + hide() { + this.clearSelection(); + this.container.remove(); + } + + private clearSelection() { + if (!this.selectedElement) return; + + this.selectedElement.classList.remove(selectedSuggestionClassName); + this.selectedElement = null; + } + + private updateSelection(targetItem: HTMLElement) { + this.clearSelection(); + + this.selectedElement = targetItem; + this.selectedElement.classList.add(selectedSuggestionClassName); + } + + renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup { + this.clearSelection(); + + this.listElement.innerHTML = ''; + + for (const suggestedTerm of suggestions) { + const listItem = makeEl('li', { + className: 'autocomplete__item', + innerText: suggestedTerm.label, + }); + + listItem.dataset.value = suggestedTerm.value; + + this.watchItem(listItem, suggestedTerm); + this.listElement.appendChild(listItem); + } + + return this; + } + + private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) { + mouseMoveThenOver(listItem, () => this.updateSelection(listItem)); + + listItem.addEventListener('mouseout', () => this.clearSelection()); + + listItem.addEventListener('click', () => { + if (!listItem.dataset.value) { + return; + } + + this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion })); + }); + } + + private changeSelection(direction: number) { + if (this.listElement.childElementCount === 0 || direction === 0) { + return; + } + + let nextTargetElement: Element | null; + + if (!this.selectedElement) { + nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild; + } else { + nextTargetElement = + direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling; + } + + if (!(nextTargetElement instanceof HTMLElement) || !nextTargetElement.dataset.value) { + this.clearSelection(); + return; + } + + this.updateSelection(nextTargetElement); + } + + selectNext() { + return this.changeSelection(1); + } + + selectPrevious() { + return this.changeSelection(-1); + } + + showForField(targetElement: HTMLElement): SuggestionsPopup { + this.container.style.position = 'absolute'; + this.container.style.left = `${targetElement.offsetLeft}px`; + + let topPosition = targetElement.offsetTop + targetElement.offsetHeight; + + if (targetElement.parentElement) { + topPosition -= targetElement.parentElement.scrollTop; + } + + this.container.style.top = `${topPosition}px`; + + document.body.appendChild(this.container); + + return this; + } + + onItemSelected(callback: (event: CustomEvent) => void) { + this.container.addEventListener('item_selected', callback as EventListener); + } +} + +const cachedSuggestions = new Map>(); + +export async function fetchSuggestions(endpoint: string, targetTerm: string) { + const normalizedTerm = targetTerm.trim().toLowerCase(); + + if (cachedSuggestions.has(normalizedTerm)) { + return cachedSuggestions.get(normalizedTerm)!; + } + + const promisedSuggestions: Promise = fetch(`${endpoint}${targetTerm}`) + .then(handleError) + .then(response => response.json()) + .catch(() => { + // Deleting the promised result from cache to allow retrying + cachedSuggestions.delete(normalizedTerm); + + // And resolve failed promise with empty array + return []; + }); + + cachedSuggestions.set(normalizedTerm, promisedSuggestions); + + return promisedSuggestions; +} From 997b1bbe8af09a18a50672979b46baf34be2c8db Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:11:53 +0400 Subject: [PATCH 098/115] Extracted local autocompleter download function --- assets/js/autocomplete.ts | 26 ++++++++++---------------- assets/js/utils/suggestions.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 4a769ef3..8fac48ea 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -8,7 +8,7 @@ import { getTermContexts } from './match_query'; import store from './utils/store'; import { TermContext } from './query/lex'; import { $$ } from './utils/dom'; -import { fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; +import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; let inputField: HTMLInputElement | null = null, originalTerm: string | undefined, @@ -118,13 +118,13 @@ function listenAutocomplete() { let serverSideSuggestionsTimeout: number | undefined; let localAc: LocalAutocompleter | null = null; - let localFetched = false; + let isLocalLoading = false; - document.addEventListener('focusin', fetchLocalAutocomplete); + document.addEventListener('focusin', loadAutocompleteFromEvent); document.addEventListener('input', event => { popup.hide(); - fetchLocalAutocomplete(event); + loadAutocompleteFromEvent(event); window.clearTimeout(serverSideSuggestionsTimeout); if (!(event.target instanceof HTMLInputElement)) return; @@ -195,21 +195,15 @@ function listenAutocomplete() { } }); - function fetchLocalAutocomplete(event: Event) { + function loadAutocompleteFromEvent(event: Event) { if (!(event.target instanceof HTMLInputElement)) return; - if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) { - const now = new Date(); - const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + if (!isLocalLoading && event.target.dataset.ac) { + isLocalLoading = true; - localFetched = true; - - fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' }) - .then(handleError) - .then(resp => resp.arrayBuffer()) - .then(buf => { - localAc = new LocalAutocompleter(buf); - }); + fetchLocalAutocomplete().then(autocomplete => { + localAc = autocomplete; + }); } } diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 7f4e9637..e9071c34 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -1,6 +1,7 @@ import { makeEl } from './dom.ts'; import { mouseMoveThenOver } from './events.ts'; import { handleError } from './requests.ts'; +import { LocalAutocompleter } from './local-autocompleter.ts'; export interface TermSuggestion { label: string; @@ -163,3 +164,20 @@ export async function fetchSuggestions(endpoint: string, targetTerm: string) { return promisedSuggestions; } + +export function purgeSuggestionsCache() { + cachedSuggestions.clear(); +} + +export async function fetchLocalAutocomplete(): Promise { + const now = new Date(); + const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + + return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { + credentials: 'omit', + cache: 'force-cache', + }) + .then(handleError) + .then(resp => resp.arrayBuffer()) + .then(buf => new LocalAutocompleter(buf)); +} From 4b3348aceecc20c6274724ad4c1459634f2bdf3e Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:24:41 +0400 Subject: [PATCH 099/115] Tests: Covering server-side suggestions and local suggestions functions --- assets/js/utils/__tests__/suggestions.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 assets/js/utils/__tests__/suggestions.spec.ts diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts new file mode 100644 index 00000000..f7eaa489 --- /dev/null +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -0,0 +1,128 @@ +import { fetchMock } from '../../../test/fetch-mock.ts'; +import { fetchLocalAutocomplete, fetchSuggestions, purgeSuggestionsCache } from '../suggestions.ts'; +import fs from 'fs'; +import path from 'path'; +import { LocalAutocompleter } from '../local-autocompleter.ts'; + +const mockedSuggestionsEndpoint = '/endpoint?term='; +const mockedSuggestionsResponse = [ + { label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' }, + { label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' }, + { label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' }, + { label: 'artist:devinian (1)', value: 'artist:devinian' }, + { label: 'artist:moe (1)', value: 'artist:moe' }, +]; + +describe('Suggestions', () => { + let mockedAutocompleteBuffer: ArrayBuffer; + + beforeAll(async () => { + fetchMock.enableMocks(); + + mockedAutocompleteBuffer = await fs.promises + .readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin')) + .then(fileBuffer => fileBuffer.buffer); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + purgeSuggestionsCache(); + fetchMock.resetMocks(); + }); + + describe('fetchSuggestions', () => { + it('should only call fetch once per single term', () => { + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should be case-insensitive to terms and trim spaces', () => { + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + fetchSuggestions(mockedSuggestionsEndpoint, 'Art'); + fetchSuggestions(mockedSuggestionsEndpoint, ' ART '); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should return the same suggestions from cache', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(firstSuggestions).toBe(secondSuggestions); + }); + + it('should parse and return array of suggestions', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length); + expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse); + }); + + it('should return empty array on server error', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(0); + }); + + it('should return empty array on invalid response format', async () => { + fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(0); + }); + }); + + describe('purgeSuggestionsCache', () => { + it('should clear cached responses', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + purgeSuggestionsCache(); + const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(fetch).toBeCalledTimes(2); + expect(firstResult).not.toBe(resultAfterPurge); + }); + }); + + describe('fetchLocalAutocomplete', () => { + it('should request binary with date-related cache key', () => { + const now = new Date(); + const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`; + + fetchLocalAutocomplete(); + + expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' }); + }); + + it('should return auto-completer instance', async () => { + fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); + + const autocomplete = await fetchLocalAutocomplete(); + + expect(autocomplete).toBeInstanceOf(LocalAutocompleter); + }); + + it('should throw generic server error on failing response', async () => { + fetchMock.mockResolvedValue(new Response('error', { status: 500 })); + + expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server'); + }); + }); +}); From ab43c42e5301b006fc02c248c8aac9d31e4bd66b Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 21:35:05 +0400 Subject: [PATCH 100/115] Removed unnecessary import --- assets/js/autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 8fac48ea..489392c3 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -3,7 +3,6 @@ */ import { LocalAutocompleter } from './utils/local-autocompleter'; -import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; import { TermContext } from './query/lex'; From 914aa75a8e5bcd42eae07df96f03c67a22dac6a8 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:19:46 +0400 Subject: [PATCH 101/115] Tests: Covering `SuggestionsPopup` with tests --- assets/js/utils/__tests__/suggestions.spec.ts | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index f7eaa489..b65ca94f 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -1,8 +1,16 @@ import { fetchMock } from '../../../test/fetch-mock.ts'; -import { fetchLocalAutocomplete, fetchSuggestions, purgeSuggestionsCache } from '../suggestions.ts'; +import { + fetchLocalAutocomplete, + fetchSuggestions, + purgeSuggestionsCache, + SuggestionsPopup, + TermSuggestion, +} from '../suggestions.ts'; import fs from 'fs'; import path from 'path'; import { LocalAutocompleter } from '../local-autocompleter.ts'; +import { afterEach } from 'vitest'; +import { fireEvent } from '@testing-library/dom'; const mockedSuggestionsEndpoint = '/endpoint?term='; const mockedSuggestionsResponse = [ @@ -13,8 +21,26 @@ const mockedSuggestionsResponse = [ { label: 'artist:moe (1)', value: 'artist:moe' }, ]; +function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { + const input = document.createElement('input'); + const popup = new SuggestionsPopup(); + + document.body.append(input); + popup.showForField(input); + + if (includeMockedSuggestions) { + popup.renderSuggestions(mockedSuggestionsResponse); + } + + return [popup, input]; +} + +const selectedItemClassName = 'autocomplete__item--selected'; + describe('Suggestions', () => { let mockedAutocompleteBuffer: ArrayBuffer; + let popup: SuggestionsPopup | undefined; + let input: HTMLInputElement | undefined; beforeAll(async () => { fetchMock.enableMocks(); @@ -33,6 +59,136 @@ describe('Suggestions', () => { fetchMock.resetMocks(); }); + afterEach(() => { + if (input) { + input.remove(); + input = undefined; + } + + if (popup) { + popup.hide(); + popup = undefined; + } + }); + + describe('SuggestionsPopup', () => { + it('should create the popup container', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(true); + }); + + it('should be removed when hidden', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.hide(); + + expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(false); + }); + + it('should render suggestions', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length); + }); + + it('should initially select first element when selectNext called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectNext(); + + expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); + }); + + it('should initially select last element when selectPrevious called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectPrevious(); + + expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); + }); + + it('should select and de-select items when hovering items over', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const firstItem = document.querySelector('.autocomplete__item:first-child'); + const lastItem = document.querySelector('.autocomplete__item:last-child'); + + if (firstItem) { + fireEvent.mouseOver(firstItem); + fireEvent.mouseMove(firstItem); + } + + expect(firstItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOver(lastItem); + fireEvent.mouseMove(lastItem); + } + + expect(firstItem).not.toHaveClass(selectedItemClassName); + expect(lastItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOut(lastItem); + } + + expect(lastItem).not.toHaveClass(selectedItemClassName); + }); + + it('should allow switching between mouse and selection', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const secondItem = document.querySelector('.autocomplete__item:nth-child(2)'); + const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)'); + + if (secondItem) { + fireEvent.mouseOver(secondItem); + fireEvent.mouseMove(secondItem); + } + + expect(secondItem).toHaveClass(selectedItemClassName); + + popup.selectNext(); + + expect(secondItem).not.toHaveClass(selectedItemClassName); + expect(thirdItem).toHaveClass(selectedItemClassName); + }); + + it('should return selected item value', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(popup.selectedTerm).toBe(null); + + popup.selectNext(); + + expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value); + }); + + it('should emit an event when item was clicked with mouse', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + let clickEvent: CustomEvent | undefined; + + const itemSelectedHandler = vi.fn((event: CustomEvent) => { + clickEvent = event; + }); + + popup.onItemSelected(itemSelectedHandler); + + const firstItem = document.querySelector('.autocomplete__item'); + + if (firstItem) { + fireEvent.click(firstItem); + } + + expect(itemSelectedHandler).toBeCalledTimes(1); + expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); + }); + }); + describe('fetchSuggestions', () => { it('should only call fetch once per single term', () => { fetchSuggestions(mockedSuggestionsEndpoint, 'art'); @@ -102,6 +258,8 @@ describe('Suggestions', () => { describe('fetchLocalAutocomplete', () => { it('should request binary with date-related cache key', () => { + fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); + const now = new Date(); const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`; From 7a6ca5b2340a2003808d6fea8966794cd81ebd63 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:32:55 +0400 Subject: [PATCH 102/115] Tests: Check selection when clicked element has no value --- assets/js/utils/__tests__/suggestions.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index b65ca94f..c9a248d0 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -187,6 +187,24 @@ describe('Suggestions', () => { expect(itemSelectedHandler).toBeCalledTimes(1); expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); }); + + it('should not emit selection on items without value', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.renderSuggestions([{ label: 'Option without value', value: '' }]); + + const itemSelectionHandler = vi.fn(); + + popup.onItemSelected(itemSelectionHandler); + + const firstItem = document.querySelector('.autocomplete__item:first-child')!; + + if (firstItem) { + fireEvent.click(firstItem); + } + + expect(itemSelectionHandler).not.toBeCalled(); + }); }); describe('fetchSuggestions', () => { From 0111ac5dfb591559faaa5f125e493223606a308f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:36:07 +0400 Subject: [PATCH 103/115] Fixed selection using keyboard when stumbled upon option without value --- assets/js/utils/suggestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index e9071c34..6f9522a1 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -102,7 +102,7 @@ export class SuggestionsPopup { direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling; } - if (!(nextTargetElement instanceof HTMLElement) || !nextTargetElement.dataset.value) { + if (!(nextTargetElement instanceof HTMLElement)) { this.clearSelection(); return; } From 098ec6c6db681a9701d7e5e01a8a040fc3d1e13c Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:56:44 +0400 Subject: [PATCH 104/115] Removed the check for direction and elements count for private method --- assets/js/utils/suggestions.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 6f9522a1..416546a7 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -89,10 +89,6 @@ export class SuggestionsPopup { } private changeSelection(direction: number) { - if (this.listElement.childElementCount === 0 || direction === 0) { - return; - } - let nextTargetElement: Element | null; if (!this.selectedElement) { From 1fe752dca3aba65e6906d91fa68af905eb81a747 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 31 Aug 2024 23:57:24 +0400 Subject: [PATCH 105/115] Tests: Looping selection between from end to start and from start to end --- assets/js/utils/__tests__/suggestions.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index c9a248d0..59102d2b 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -157,6 +157,36 @@ describe('Suggestions', () => { expect(thirdItem).toHaveClass(selectedItemClassName); }); + it('should loop around when selecting next on last and previous on first', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const firstItem = document.querySelector('.autocomplete__item:first-child'); + const lastItem = document.querySelector('.autocomplete__item:last-child'); + + if (lastItem) { + fireEvent.mouseOver(lastItem); + fireEvent.mouseMove(lastItem); + } + + expect(lastItem).toHaveClass(selectedItemClassName); + + popup.selectNext(); + + expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); + + popup.selectNext(); + + expect(firstItem).toHaveClass(selectedItemClassName); + + popup.selectPrevious(); + + expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); + + popup.selectPrevious(); + + expect(lastItem).toHaveClass(selectedItemClassName); + }); + it('should return selected item value', () => { [popup, input] = mockBaseSuggestionsPopup(true); From 18c72814a9f5a38ae8732321b140898b169ce75c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 2 Sep 2024 09:32:50 -0400 Subject: [PATCH 106/115] Annotate return values --- assets/js/utils/suggestions.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 416546a7..fb810be3 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -107,14 +107,14 @@ export class SuggestionsPopup { } selectNext() { - return this.changeSelection(1); + this.changeSelection(1); } selectPrevious() { - return this.changeSelection(-1); + this.changeSelection(-1); } - showForField(targetElement: HTMLElement): SuggestionsPopup { + showForField(targetElement: HTMLElement) { this.container.style.position = 'absolute'; this.container.style.left = `${targetElement.offsetLeft}px`; @@ -127,8 +127,6 @@ export class SuggestionsPopup { this.container.style.top = `${topPosition}px`; document.body.appendChild(this.container); - - return this; } onItemSelected(callback: (event: CustomEvent) => void) { @@ -138,7 +136,7 @@ export class SuggestionsPopup { const cachedSuggestions = new Map>(); -export async function fetchSuggestions(endpoint: string, targetTerm: string) { +export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise { const normalizedTerm = targetTerm.trim().toLowerCase(); if (cachedSuggestions.has(normalizedTerm)) { From 516f4a98fd5167c91e1fa345c22a1fcf1c995586 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 2 Sep 2024 17:38:35 -0400 Subject: [PATCH 107/115] Fix range function clauses --- lib/philomena_query/parse/parser.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/philomena_query/parse/parser.ex b/lib/philomena_query/parse/parser.ex index e615653f..1b5f269d 100644 --- a/lib/philomena_query/parse/parser.ex +++ b/lib/philomena_query/parse/parser.ex @@ -361,6 +361,9 @@ defmodule PhilomenaQuery.Parse.Parser do {%{wildcard: %{field(parser, field_name) => normalize_value(parser, field_name, value)}}, []}} + defp field_type(_parser, [{LiteralParser, field_name}, _range, _value]), + do: {:error, "range specified for " <> field_name} + defp field_type(parser, [{NgramParser, field_name}, range: :eq, literal: value]), do: {:ok, @@ -384,12 +387,21 @@ defmodule PhilomenaQuery.Parse.Parser do {%{wildcard: %{field(parser, field_name) => normalize_value(parser, field_name, value)}}, []}} + defp field_type(_parser, [{NgramParser, field_name}, _range, _value]), + do: {:error, "range specified for " <> field_name} + defp field_type(parser, [{BoolParser, field_name}, range: :eq, bool: value]), do: {:ok, {%{term: %{field(parser, field_name) => value}}, []}} + defp field_type(_parser, [{BoolParser, field_name}, _range, _value]), + do: {:error, "range specified for " <> field_name} + defp field_type(parser, [{IpParser, field_name}, range: :eq, ip: value]), do: {:ok, {%{term: %{field(parser, field_name) => value}}, []}} + defp field_type(_parser, [{IpParser, field_name}, _range, _value]), + do: {:error, "range specified for " <> field_name} + # Types which do support ranges defp field_type(parser, [{IntParser, field_name}, range: :eq, int: value]), From 2e88374ac15559f187d0403087b564e8cf342dbc Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 10 Sep 2024 10:54:17 -0400 Subject: [PATCH 108/115] quick fixup --- assets/js/__tests__/upload.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts index bd5d9c5e..027241f8 100644 --- a/assets/js/__tests__/upload.spec.ts +++ b/assets/js/__tests__/upload.spec.ts @@ -214,7 +214,7 @@ describe('Image upload form', () => { }); }); - async function submitForm(frm): Promise { + async function submitForm(frm: HTMLFormElement): Promise { return new Promise(resolve => { function onSubmit() { frm.removeEventListener('submit', onSubmit); @@ -232,7 +232,7 @@ describe('Image upload form', () => { it('should prevent form submission if tag checks fail', async () => { for (let i = 0; i < tagSets.length; i += 1) { - taginputEl.value = tagSets[i]; + taginputEl.innerText = tagSets[i]; if (await submitForm(form)) { // form submit succeeded @@ -243,7 +243,7 @@ describe('Image upload form', () => { }); } else { // form submit prevented - frm = form; + const frm = form; await waitFor(() => { assertSubmitButtonIsEnabled(); expect(frm.querySelectorAll('.help-block')).toHaveLength(tagErrorCounts[i]); From be771331360cd887ab01fddbe9c3043b58eea8d4 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 10 Sep 2024 13:26:18 -0400 Subject: [PATCH 109/115] Handle error result for API reverse search --- .../controllers/api/json/search/reverse_controller.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index 13095c2e..4abe7560 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -10,17 +10,17 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do def create(conn, %{"image" => image_params}) do user = conn.assigns.current_user - images = + {images, total} = image_params |> Map.put("distance", conn.params["distance"]) |> Map.put("limit", conn.params["limit"]) |> DuplicateReports.execute_search_query() |> case do {:ok, images} -> - images + {images, images.total_entries} {:error, _changeset} -> - [] + {[], 0} end interactions = Interactions.user_interactions(images, user) @@ -29,7 +29,7 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do |> put_view(PhilomenaWeb.Api.Json.ImageView) |> render("index.json", images: images, - total: images.total_entries, + total: total, interactions: interactions ) end From 1dbae45ede17f849639d5936e47cc22213da5808 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 10 Sep 2024 14:23:05 -0400 Subject: [PATCH 110/115] Fix handling for nil upload --- lib/philomena/images/thumbnailer.ex | 4 ++-- lib/philomena_media/analyzers.ex | 31 ++++++++++++++++++----------- lib/philomena_media/uploader.ex | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/philomena/images/thumbnailer.ex b/lib/philomena/images/thumbnailer.ex index 8c566135..b8be742b 100644 --- a/lib/philomena/images/thumbnailer.ex +++ b/lib/philomena/images/thumbnailer.ex @@ -76,7 +76,7 @@ defmodule Philomena.Images.Thumbnailer do def generate_thumbnails(image_id) do image = Repo.get!(Image, image_id) file = download_image_file(image) - {:ok, analysis} = Analyzers.analyze(file) + {:ok, analysis} = Analyzers.analyze_path(file) file = apply_edit_script(image, file, Processors.process(analysis, file, generated_sizes(image))) @@ -127,7 +127,7 @@ defmodule Philomena.Images.Thumbnailer do end defp recompute_meta(image, file, changeset_fn) do - {:ok, %{dimensions: {width, height}}} = Analyzers.analyze(file) + {:ok, %{dimensions: {width, height}}} = Analyzers.analyze_path(file) image |> changeset_fn.(%{ diff --git a/lib/philomena_media/analyzers.ex b/lib/philomena_media/analyzers.ex index efa49d9a..7c97e845 100644 --- a/lib/philomena_media/analyzers.ex +++ b/lib/philomena_media/analyzers.ex @@ -40,25 +40,32 @@ defmodule PhilomenaMedia.Analyzers do def analyzer(_content_type), do: :error @doc """ - Attempts a MIME type check and analysis on the given path or `m:Plug.Upload`. + Attempts a MIME type check and analysis on the given `m:Plug.Upload`. + + ## Examples + + file = %Plug.Upload{...} + {:ok, %Result{...}} = Analyzers.analyze_upload(file) + + """ + @spec analyze_upload(Plug.Upload.t()) :: + {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error + def analyze_upload(%Plug.Upload{path: path}), do: analyze_path(path) + def analyze_upload(_upload), do: :error + + @doc """ + Attempts a MIME type check and analysis on the given path. ## Examples file = "image_file.png" - {:ok, %Result{...}} = Analyzers.analyze(file) - - file = %Plug.Upload{...} - {:ok, %Result{...}} = Analyzers.analyze(file) + {:ok, %Result{...}} = Analyzers.analyze_path(file) file = "text_file.txt" - :error = Analyzers.analyze(file) + :error = Analyzers.analyze_path(file) """ - @spec analyze(Plug.Upload.t() | Path.t()) :: - {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error - def analyze(%Plug.Upload{path: path}), do: analyze(path) - - def analyze(path) when is_binary(path) do + def analyze_path(path) when is_binary(path) do with {:ok, mime} <- Mime.file(path), {:ok, analyzer} <- analyzer(mime) do {:ok, analyzer.analyze(path)} @@ -68,5 +75,5 @@ defmodule PhilomenaMedia.Analyzers do end end - def analyze(_path), do: :error + def analyze_path(_path), do: :error end diff --git a/lib/philomena_media/uploader.ex b/lib/philomena_media/uploader.ex index f15e1b9c..7248d92c 100644 --- a/lib/philomena_media/uploader.ex +++ b/lib/philomena_media/uploader.ex @@ -210,7 +210,7 @@ defmodule PhilomenaMedia.Uploader do (schema_or_changeset(), map() -> Ecto.Changeset.t()) ) :: Ecto.Changeset.t() def analyze_upload(schema_or_changeset, field_name, upload_parameter, changeset_fn) do - with {:ok, analysis} <- Analyzers.analyze(upload_parameter), + with {:ok, analysis} <- Analyzers.analyze_upload(upload_parameter), analysis <- extra_attributes(analysis, upload_parameter) do removed = schema_or_changeset From 7cad23133ad461de9af9075da0f524c135edd514 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 10 Sep 2024 14:23:49 -0400 Subject: [PATCH 111/115] Ensure assets build in CI --- .github/workflows/elixir.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 25558653..a92aa72f 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -78,3 +78,6 @@ jobs: - run: npm run test working-directory: ./assets + + - run: npm run build + working-directory: ./assets \ No newline at end of file From 8e4f439a5a4cf21b577c6ffdeef53759f6ec74cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:24:50 +0000 Subject: [PATCH 112/115] Bump micromatch from 4.0.7 to 4.0.8 in /assets Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8. - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8) --- updated-dependencies: - dependency-name: micromatch dependency-type: indirect ... Signed-off-by: dependabot[bot] --- assets/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index acd3d7fc..8ba664e3 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -3711,9 +3711,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" From 1f3ee3c91058e62aade6fbb509d98a702ce6ae45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Sep 2024 18:51:04 +0000 Subject: [PATCH 113/115] Bump vite from 5.4.0 to 5.4.6 in /assets Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.0 to 5.4.6. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- assets/package-lock.json | 34 +++++++++++++++++----------------- assets/package.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index 8ba664e3..4ccada05 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -15,7 +15,7 @@ "sass": "^1.75.0", "typescript": "^5.4", "typescript-eslint": "8.0.0-alpha.39", - "vite": "^5.2" + "vite": "^5.4" }, "devDependencies": { "@testing-library/dom": "^10.1.0", @@ -4051,9 +4051,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -4078,9 +4078,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -4097,8 +4097,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4425,9 +4425,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -4784,13 +4784,13 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" diff --git a/assets/package.json b/assets/package.json index 9da50439..ad514620 100644 --- a/assets/package.json +++ b/assets/package.json @@ -20,7 +20,7 @@ "sass": "^1.75.0", "typescript": "^5.4", "typescript-eslint": "8.0.0-alpha.39", - "vite": "^5.2" + "vite": "^5.4" }, "devDependencies": { "@testing-library/dom": "^10.1.0", From 09c93f3215dd060151a71048734c344d2ee3027b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 03:51:25 +0000 Subject: [PATCH 114/115] Bump rollup from 4.20.0 to 4.22.4 in /assets Bumps [rollup](https://github.com/rollup/rollup) from 4.20.0 to 4.22.4. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v4.20.0...v4.22.4) --- updated-dependencies: - dependency-name: rollup dependency-type: indirect ... Signed-off-by: dependabot[bot] --- assets/package-lock.json | 134 +++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index 4ccada05..300a9f34 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -825,9 +825,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", - "integrity": "sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -837,9 +837,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.20.0.tgz", - "integrity": "sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -849,9 +849,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.20.0.tgz", - "integrity": "sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -861,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.20.0.tgz", - "integrity": "sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -873,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.20.0.tgz", - "integrity": "sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -885,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.20.0.tgz", - "integrity": "sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -897,9 +897,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.20.0.tgz", - "integrity": "sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -909,9 +909,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.20.0.tgz", - "integrity": "sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -921,9 +921,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.20.0.tgz", - "integrity": "sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -933,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.20.0.tgz", - "integrity": "sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -945,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.20.0.tgz", - "integrity": "sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -957,9 +957,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.20.0.tgz", - "integrity": "sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -969,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.20.0.tgz", - "integrity": "sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -981,9 +981,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.20.0.tgz", - "integrity": "sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -993,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.20.0.tgz", - "integrity": "sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1005,9 +1005,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.20.0.tgz", - "integrity": "sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -4266,9 +4266,9 @@ } }, "node_modules/rollup": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.20.0.tgz", - "integrity": "sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dependencies": { "@types/estree": "1.0.5" }, @@ -4280,22 +4280,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.20.0", - "@rollup/rollup-android-arm64": "4.20.0", - "@rollup/rollup-darwin-arm64": "4.20.0", - "@rollup/rollup-darwin-x64": "4.20.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.20.0", - "@rollup/rollup-linux-arm-musleabihf": "4.20.0", - "@rollup/rollup-linux-arm64-gnu": "4.20.0", - "@rollup/rollup-linux-arm64-musl": "4.20.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.20.0", - "@rollup/rollup-linux-riscv64-gnu": "4.20.0", - "@rollup/rollup-linux-s390x-gnu": "4.20.0", - "@rollup/rollup-linux-x64-gnu": "4.20.0", - "@rollup/rollup-linux-x64-musl": "4.20.0", - "@rollup/rollup-win32-arm64-msvc": "4.20.0", - "@rollup/rollup-win32-ia32-msvc": "4.20.0", - "@rollup/rollup-win32-x64-msvc": "4.20.0", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, From 34c9f76330de993d1d966797950dd25897f7e9ae Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 27 Sep 2024 19:53:06 -0400 Subject: [PATCH 115/115] Fix tag short description indexing --- lib/philomena/tags/search_index.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/philomena/tags/search_index.ex b/lib/philomena/tags/search_index.ex index ec681a3f..4589c065 100644 --- a/lib/philomena/tags/search_index.ex +++ b/lib/philomena/tags/search_index.ex @@ -71,7 +71,7 @@ defmodule Philomena.Tags.SearchIndex do category: tag.category, aliased: !!tag.aliased_tag, description: tag.description, - short_description: tag.description + short_description: tag.short_description } end end