diff --git a/assets/css/views/_images.scss b/assets/css/views/_images.scss index 0f9a0f44..a01e0f55 100644 --- a/assets/css/views/_images.scss +++ b/assets/css/views/_images.scss @@ -322,3 +322,16 @@ span.spoiler div.image-container { .full-height { height: 100%; } + +.image_sources { + display: grid; + grid-template-columns: 2em auto; +} + +.image_source__icon, .image_source__link { + padding: 0.5em; +} + +.image_source__icon { + justify-self: center; +} diff --git a/assets/js/input-duplicator.js b/assets/js/input-duplicator.js new file mode 100644 index 00000000..2ffa89bc --- /dev/null +++ b/assets/js/input-duplicator.js @@ -0,0 +1,83 @@ +import { $, $$, disableEl, enableEl, removeEl } from './utils/dom'; +import { delegate, leftClick } from './utils/events'; + +/** + * @typedef InputDuplicatorOptions + * @property {string} addButtonSelector + * @property {string} fieldSelector + * @property {string} maxInputCountSelector + * @property {string} removeButtonSelector + */ + +/** + * @param {InputDuplicatorOptions} options + */ +function inputDuplicatorCreator({ + addButtonSelector, + fieldSelector, + maxInputCountSelector, + removeButtonSelector +}) { + const addButton = $(addButtonSelector); + if (!addButton) { + return; + } + + const form = addButton.closest('form'); + const fieldRemover = (event, target) => { + event.preventDefault(); + + // Prevent removing the final field element to not "brick" the form + const existingFields = $$(fieldSelector, form); + if (existingFields.length <= 1) { + return; + } + + removeEl(target.closest(fieldSelector)); + enableEl(addButton); + }; + + delegate(document, 'click', { + [removeButtonSelector]: leftClick(fieldRemover) + }); + + + const maxOptionCount = parseInt($(maxInputCountSelector, form).innerHTML, 10); + addButton.addEventListener('click', e => { + e.preventDefault(); + + const existingFields = $$(fieldSelector, form); + let existingFieldsLength = existingFields.length; + if (existingFieldsLength < maxOptionCount) { + // The last element matched by the `fieldSelector` will be the last field, make a copy + const prevField = existingFields[existingFieldsLength - 1]; + const prevFieldCopy = prevField.cloneNode(true); + const prevFieldCopyInputs = $$('input', prevFieldCopy); + prevFieldCopyInputs.forEach(prevFieldCopyInput => { + // Reset new input's value + prevFieldCopyInput.value = ''; + prevFieldCopyInput.removeAttribute('value'); + // Increment sequential attributes of the input + ['name', 'id'].forEach(attr => { + prevFieldCopyInput.setAttribute(attr, prevFieldCopyInput[attr].replace(/\d+/g, `${existingFieldsLength}`)); + }); + }); + + // Insert copy before the last field's next sibling, or if none, at the end of its parent + if (prevField.nextElementSibling) { + prevField.parentNode.insertBefore(prevFieldCopy, prevField.nextElementSibling); + } + else { + prevField.parentNode.appendChild(prevFieldCopy); + } + existingFieldsLength++; + } + + // Remove the button if we reached the max number of options + if (existingFieldsLength >= maxOptionCount) { + disableEl(addButton); + } + }); +} + +export { inputDuplicatorCreator }; diff --git a/assets/js/poll.js b/assets/js/poll.js index 0687bff3..68debf5d 100644 --- a/assets/js/poll.js +++ b/assets/js/poll.js @@ -1,41 +1,11 @@ -import { $, $$, removeEl} from './utils/dom'; -import { delegate, leftClick } from './utils/events'; - -function pollOptionRemover(_event, target) { - removeEl(target.closest('.js-poll-option')); -} +import { inputDuplicatorCreator } from './input-duplicator'; function pollOptionCreator() { - const addPollOptionButton = $('.js-poll-add-option'); - - delegate(document, 'click', { - '.js-option-remove': leftClick(pollOptionRemover) - }); - - if (!addPollOptionButton) { - return; - } - - const form = addPollOptionButton.closest('form'); - const maxOptionCount = parseInt($('.js-max-option-count', form).innerHTML, 10); - addPollOptionButton.addEventListener('click', e => { - e.preventDefault(); - - let existingOptionCount = $$('.js-poll-option', form).length; - if (existingOptionCount < maxOptionCount) { - // The element right before the add button will always be the last field, make a copy - const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true); - const newHtml = prevFieldCopy.outerHTML.replace(/(\d+)/g, `${existingOptionCount}`); - - // Insert copy before the button - addPollOptionButton.insertAdjacentHTML('beforebegin', newHtml); - existingOptionCount++; - } - - // Remove the button if we reached the max number of options - if (existingOptionCount >= maxOptionCount) { - removeEl(addPollOptionButton); - } + inputDuplicatorCreator({ + addButtonSelector: '.js-poll-add-option', + fieldSelector: '.js-poll-option', + maxInputCountSelector: '.js-max-option-count', + removeButtonSelector: '.js-option-remove', }); } diff --git a/assets/js/sources.js b/assets/js/sources.js new file mode 100644 index 00000000..210382fc --- /dev/null +++ b/assets/js/sources.js @@ -0,0 +1,12 @@ +import { inputDuplicatorCreator } from './input-duplicator'; + +function imageSourcesCreator() { + inputDuplicatorCreator({ + addButtonSelector: '.js-image-add-source', + fieldSelector: '.js-image-source', + maxInputCountSelector: '.js-max-source-count', + removeButtonSelector: '.js-source-remove', + }); +} + +export { imageSourcesCreator }; diff --git a/assets/js/upload.js b/assets/js/upload.js index 8f00903f..f8800b73 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -3,7 +3,7 @@ */ import { fetchJson, handleError } from './utils/requests'; -import { $, $$, hideEl, showEl, makeEl, clearEl } from './utils/dom'; +import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom'; import { addTag } from './tagsinput'; const MATROSKA_MAGIC = 0x1a45dfa3; @@ -31,8 +31,10 @@ function setupImageUpload() { if (!imgPreviews) return; const form = imgPreviews.closest('form'); - const [ fileField, remoteUrl, scraperError ] = $$('.js-scraper', form); - const [ sourceEl, tagsEl, descrEl ] = $$('.js-image-input', form); + const [fileField, remoteUrl, scraperError] = $$('.js-scraper', form); + const descrEl = $('.js-image-descr-input', form); + const tagsEl = $('.js-image-tags-input', form); + const sourceEl = $$('.js-image-source', form).find(input => input.value === ''); const fetchButton = $('#js-scraper-preview'); if (!fetchButton) return; diff --git a/assets/js/utils/__tests__/dom.spec.ts b/assets/js/utils/__tests__/dom.spec.ts index d0c65098..a12d6e1f 100644 --- a/assets/js/utils/__tests__/dom.spec.ts +++ b/assets/js/utils/__tests__/dom.spec.ts @@ -12,6 +12,8 @@ import { toggleEl, whenReady, findFirstTextNode, + disableEl, + enableEl, } from '../dom'; import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness'; import { fireEvent } from '@testing-library/dom'; @@ -149,6 +151,74 @@ describe('DOM Utilities', () => { }); }); + describe('disableEl', () => { + it('should set the disabled attribute to true', () => { + const mockElement = document.createElement('button'); + disableEl(mockElement); + expect(mockElement).toBeDisabled(); + }); + + it('should set the disabled attribute to true on all provided elements', () => { + 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'), + ]; + disableEl(mockElements1, mockElements2); + expect(mockElements1[0]).toBeDisabled(); + expect(mockElements1[1]).toBeDisabled(); + expect(mockElements2[0]).toBeDisabled(); + expect(mockElements2[1]).toBeDisabled(); + }); + }); + + describe('enableEl', () => { + it('should set the disabled attribute to false', () => { + const mockElement = document.createElement('button'); + enableEl(mockElement); + expect(mockElement).toBeEnabled(); + }); + + it('should set the disabled attribute to false on all provided elements', () => { + 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'), + ]; + enableEl(mockElements1, mockElements2); + expect(mockElements1[0]).toBeEnabled(); + expect(mockElements1[1]).toBeEnabled(); + expect(mockElements2[0]).toBeEnabled(); + expect(mockElements2[1]).toBeEnabled(); + }); + }); + describe('toggleEl', () => { it(`should toggle the ${hiddenClass} class on the provided element`, () => { const mockVisibleElement = document.createElement('div'); diff --git a/assets/js/utils/dom.ts b/assets/js/utils/dom.ts index 34bfe02b..77cdfea6 100644 --- a/assets/js/utils/dom.ts +++ b/assets/js/utils/dom.ts @@ -1,5 +1,7 @@ // DOM Utils +type PhilomenaInputElements = HTMLTextAreaElement | HTMLInputElement | HTMLButtonElement; + /** * Get the first matching element */ @@ -34,6 +36,18 @@ export function clearEl(...elements: E[] | ConcatArray }); } +export function disableEl(...elements: E[] | ConcatArray[]) { + ([] as E[]).concat(...elements).forEach(el => { + el.disabled = true; + }); +} + +export function enableEl(...elements: E[] | ConcatArray[]) { + ([] as E[]).concat(...elements).forEach(el => { + el.disabled = false; + }); +} + export function removeEl(...elements: E[] | ConcatArray[]) { ([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el)); } diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js index eed3082a..b17e247b 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.js @@ -35,6 +35,7 @@ import { setupToolbar } from './markdowntoolbar'; import { hideStaffTools } from './staffhider'; import { pollOptionCreator } from './poll'; import { warnAboutPMs } from './pmwarning'; +import { imageSourcesCreator } from './sources'; whenReady(() => { @@ -68,5 +69,6 @@ whenReady(() => { hideStaffTools(); pollOptionCreator(); warnAboutPMs(); + imageSourcesCreator(); }); diff --git a/config/runtime.exs b/config/runtime.exs index d27b87c6..2c434e0b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -28,6 +28,7 @@ config :philomena, badge_url_root: System.fetch_env!("BADGE_URL_ROOT"), mailer_address: System.fetch_env!("MAILER_ADDRESS"), tag_file_root: System.fetch_env!("TAG_FILE_ROOT"), + site_domains: System.fetch_env!("SITE_DOMAINS"), tag_url_root: System.fetch_env!("TAG_URL_ROOT"), redis_host: System.get_env("REDIS_HOST", "localhost"), proxy_host: System.get_env("PROXY_HOST"), diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 44403fb8..600f1a71 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -77,10 +77,12 @@ defmodule Philomena.Images do """ def create_image(attribution, attrs \\ %{}) do tags = Tags.get_or_create_tags(attrs["tag_input"]) + sources = attrs["sources"] image = %Image{} |> Image.creation_changeset(attrs, attribution) + |> Image.source_changeset(attrs, [], sources) |> Image.tag_changeset(attrs, [], tags) |> Image.dnp_changeset(attribution[:user]) |> Uploader.analyze_upload(attrs) @@ -92,11 +94,6 @@ defmodule Philomena.Images do |> Image.cache_changeset() |> repo.update() end) - |> Multi.run(:source_change, fn repo, %{image: image} -> - %SourceChange{image_id: image.id, initial: true} - |> SourceChange.creation_changeset(attrs, attribution) - |> repo.insert() - 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) @@ -333,29 +330,69 @@ defmodule Philomena.Images do |> Repo.update() end - def update_source(%Image{} = image, attribution, attrs) do - image_changes = - image - |> Image.source_changeset(attrs) - - source_changes = - Ecto.build_assoc(image, :source_changes) - |> SourceChange.creation_changeset(attrs, attribution) + def update_sources(%Image{} = image, attribution, attrs) do + old_sources = attrs["old_sources"] + new_sources = attrs["sources"] Multi.new() - |> Multi.update(:image, image_changes) - |> Multi.run(:source_change, fn repo, _changes -> - case image_changes.changes do - %{source_url: _new_source} -> - repo.insert(source_changes) + |> Multi.run(:image, fn repo, _chg -> + image = repo.preload(image, [:sources]) - _ -> - {:ok, nil} + image + |> Image.source_changeset(%{}, old_sources, new_sources) + |> repo.update() + |> case do + {:ok, image} -> + {:ok, {image, image.added_sources, image.removed_sources}} + + error -> + error end end) + |> Multi.run(:added_source_changes, fn repo, %{image: {image, added_sources, _removed}} -> + source_changes = + added_sources + |> Enum.map(&source_change_attributes(attribution, image, &1, true, attribution[:user])) + + {count, nil} = repo.insert_all(SourceChange, source_changes) + + {:ok, count} + end) + |> Multi.run(:removed_source_changes, fn repo, %{image: {image, _added, removed_sources}} -> + source_changes = + removed_sources + |> Enum.map(&source_change_attributes(attribution, image, &1, false, attribution[:user])) + + {count, nil} = repo.insert_all(SourceChange, source_changes) + + {:ok, count} + end) |> Repo.transaction() end + defp source_change_attributes(attribution, image, source, added, user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + user_id = + case user do + nil -> nil + user -> user.id + end + + %{ + image_id: image.id, + source_url: source, + user_id: user_id, + created_at: now, + updated_at: now, + ip: attribution[:ip], + fingerprint: attribution[:fingerprint], + user_agent: attribution[:user_agent], + referrer: attribution[:referrer], + added: added + } + end + def update_locked_tags(%Image{} = image, attrs) do new_tags = Tags.get_or_create_tags(attrs["tag_input"]) @@ -511,6 +548,13 @@ defmodule Philomena.Images do |> Multi.run(:copy_tags, fn _, %{} -> {:ok, Tags.copy_tags(image, duplicate_of_image)} end) + |> Multi.run(:migrate_sources, fn repo, %{} -> + {:ok, + migrate_sources( + repo.preload(image, [:sources]), + repo.preload(duplicate_of_image, [:sources]) + )} + end) |> Multi.run(:migrate_comments, fn _, %{} -> {:ok, Comments.migrate_comments(image, duplicate_of_image)} end) @@ -787,6 +831,7 @@ defmodule Philomena.Images do :hiders, :deleter, :gallery_interactions, + :sources, tags: [:aliases, :aliased_tag] ] end @@ -882,6 +927,17 @@ defmodule Philomena.Images do {:ok, count} end + def migrate_sources(source, target) do + sources = + (source.sources ++ target.sources) + |> Enum.uniq() + |> Enum.take(10) + + target + |> Image.sources_changeset(sources) + |> Repo.update() + end + def notify_merge(source, target) do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Images", [source.id, target.id]]) end diff --git a/lib/philomena/images/elasticsearch_index.ex b/lib/philomena/images/elasticsearch_index.ex index 0936656f..b76912bd 100644 --- a/lib/philomena/images/elasticsearch_index.ex +++ b/lib/philomena/images/elasticsearch_index.ex @@ -119,7 +119,7 @@ defmodule Philomena.Images.ElasticsearchIndex do mime_type: image.image_mime_type, uploader: if(!!image.user and !image.anonymous, do: String.downcase(image.user.name)), true_uploader: if(!!image.user, do: String.downcase(image.user.name)), - source_url: image.source_url |> to_string |> String.downcase(), + source_url: image.sources |> Enum.map(&String.downcase(&1.source)), file_name: image.image_name, original_format: image.image_format, processed: image.processed, diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 34eeb64b..a9588e68 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -8,6 +8,7 @@ defmodule Philomena.Images.Image do alias Philomena.ImageVotes.ImageVote alias Philomena.ImageFaves.ImageFave alias Philomena.ImageHides.ImageHide + alias Philomena.Images.Source alias Philomena.Images.Subscription alias Philomena.Users.User alias Philomena.Tags.Tag @@ -18,6 +19,7 @@ defmodule Philomena.Images.Image do alias Philomena.Images.Image alias Philomena.Images.TagDiffer + alias Philomena.Images.SourceDiffer alias Philomena.Images.TagValidator alias Philomena.Images.DnpValidator alias Philomena.Repo @@ -42,6 +44,7 @@ defmodule Philomena.Images.Image do many_to_many :locked_tags, Tag, join_through: "image_tag_locks", on_replace: :delete has_one :intensity, ImageIntensity has_many :galleries, through: [:gallery_interactions, :image] + has_many :sources, Source, on_replace: :delete field :image, :string field :image_name, :string @@ -91,6 +94,8 @@ defmodule Philomena.Images.Image do field :removed_tags, {:array, :any}, default: [], virtual: true 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 :uploaded_image, :string, virtual: true field :removed_image, :string, virtual: true @@ -203,11 +208,15 @@ defmodule Philomena.Images.Image do |> change(image: nil) end - def source_changeset(image, attrs) do + def source_changeset(image, attrs, old_sources, new_sources) do image - |> cast(attrs, [:source_url]) - |> validate_required(:source_url) - |> validate_format(:source_url, ~r/\Ahttps?:\/\//) + |> cast(attrs, []) + |> SourceDiffer.diff_input(old_sources, new_sources) + end + + def sources_changeset(image, new_sources) do + change(image) + |> put_assoc(:sources, new_sources) end def tag_changeset(image, attrs, old_tags, new_tags, excluded_tags \\ []) do diff --git a/lib/philomena/images/source.ex b/lib/philomena/images/source.ex index d341a1d4..476872b9 100644 --- a/lib/philomena/images/source.ex +++ b/lib/philomena/images/source.ex @@ -4,9 +4,10 @@ defmodule Philomena.Images.Source do alias Philomena.Images.Image + @primary_key false schema "image_sources" do - belongs_to :image, Image - field :source, :string + belongs_to :image, Image, primary_key: true + field :source, :string, primary_key: true end @doc false diff --git a/lib/philomena/images/source_differ.ex b/lib/philomena/images/source_differ.ex new file mode 100644 index 00000000..8ac29a08 --- /dev/null +++ b/lib/philomena/images/source_differ.ex @@ -0,0 +1,70 @@ +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)) + new_set = MapSet.new(flatten_input(new_sources)) + + source_set = MapSet.new(get_field(changeset, :sources), & &1.source) + added_sources = MapSet.difference(new_set, old_set) + removed_sources = MapSet.difference(old_set, new_set) + + {sources, actually_added, actually_removed} = + apply_changes(source_set, added_sources, removed_sources) + + image_id = fetch_field!(changeset, :id) + + changeset + |> put_change(:added_sources, actually_added) + |> put_change(:removed_sources, actually_removed) + |> put_assoc(:sources, source_structs(image_id, sources)) + end + + defp apply_changes(source_set, added_set, removed_set) do + desired_sources = + source_set + |> MapSet.difference(removed_set) + |> MapSet.union(added_set) + + actually_added = + desired_sources + |> MapSet.difference(source_set) + |> Enum.to_list() + + actually_removed = + source_set + |> MapSet.difference(desired_sources) + |> Enum.to_list() + + sources = Enum.to_list(desired_sources) + actually_added = Enum.to_list(actually_added) + actually_removed = Enum.to_list(actually_removed) + + {sources, actually_added, actually_removed} + end + + defp source_structs(image_id, sources) do + Enum.map(sources, &%Source{image_id: image_id, source: &1}) + end + + defp flatten_input(input) when is_map(input) do + Enum.flat_map(Map.values(input), fn + %{"source" => source} -> + source = String.trim(source) + + if source != "" do + [source] + else + [] + end + + _ -> + [] + end) + end + + defp flatten_input(_input) do + [] + end +end diff --git a/lib/philomena/source_changes/source_change.ex b/lib/philomena/source_changes/source_change.ex index 6d6840bf..3cff4685 100644 --- a/lib/philomena/source_changes/source_change.ex +++ b/lib/philomena/source_changes/source_change.ex @@ -10,10 +10,10 @@ defmodule Philomena.SourceChanges.SourceChange do field :fingerprint, :string field :user_agent, :string, default: "" field :referrer, :string, default: "" - field :new_value, :string - field :initial, :boolean, default: false + field :value, :string + field :added, :boolean - field :source_url, :string, source: :new_value + field :source_url, :string, source: :value timestamps(inserted_at: :created_at, type: :utc_datetime) end diff --git a/lib/philomena_web/controllers/duplicate_report_controller.ex b/lib/philomena_web/controllers/duplicate_report_controller.ex index bc87711b..f30539a0 100644 --- a/lib/philomena_web/controllers/duplicate_report_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report_controller.ex @@ -29,8 +29,8 @@ defmodule PhilomenaWeb.DuplicateReportController do |> preload([ :user, :modifier, - image: [:user, tags: :aliases], - duplicate_of_image: [:user, tags: :aliases] + image: [:user, :sources, tags: :aliases], + duplicate_of_image: [:user, :sources, tags: :aliases] ]) |> order_by(desc: :created_at) |> Repo.paginate(conn.assigns.scrivener) diff --git a/lib/philomena_web/controllers/image/source_controller.ex b/lib/philomena_web/controllers/image/source_controller.ex index f741525c..373dcc10 100644 --- a/lib/philomena_web/controllers/image/source_controller.ex +++ b/lib/philomena_web/controllers/image/source_controller.ex @@ -21,19 +21,18 @@ defmodule PhilomenaWeb.Image.SourceController do plug :load_and_authorize_resource, model: Image, id_name: "image_id", - preload: [:user, tags: :aliases] + preload: [:user, :sources, tags: :aliases] def update(conn, %{"image" => image_params}) do attributes = conn.assigns.attributes image = conn.assigns.image - old_source = image.source_url - case Images.update_source(image, attributes, image_params) do - {:ok, %{image: image}} -> + case Images.update_sources(image, attributes, image_params) do + {:ok, %{image: {image, added_sources, removed_sources}}} -> PhilomenaWeb.Endpoint.broadcast!( "firehose", "image:source_update", - %{image_id: image.id, added: [image.source_url], removed: [old_source]} + %{image_id: image.id, added: [added_sources], removed: [removed_sources]} ) PhilomenaWeb.Endpoint.broadcast!( @@ -49,7 +48,7 @@ defmodule PhilomenaWeb.Image.SourceController do |> where(image_id: ^image.id) |> Repo.aggregate(:count, :id) - if old_source != image.source_url do + if Enum.any?(added_sources) or Enum.any?(removed_sources) do UserStatistics.inc_stat(conn.assigns.current_user, :metadata_updates) end diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex index 3cb2711e..122d49e2 100644 --- a/lib/philomena_web/controllers/image_controller.ex +++ b/lib/philomena_web/controllers/image_controller.ex @@ -9,6 +9,7 @@ defmodule PhilomenaWeb.ImageController do alias Philomena.{ Images, Images.Image, + Images.Source, Comments.Comment, Galleries.Gallery } @@ -79,7 +80,7 @@ defmodule PhilomenaWeb.ImageController do |> Comments.change_comment() image_changeset = - image + %{image | sources: sources_for_edit(image.sources)} |> Images.change_image() watching = Images.subscribed?(image, conn.assigns.current_user) @@ -108,7 +109,7 @@ defmodule PhilomenaWeb.ImageController do def new(conn, _params) do changeset = - %Image{} + %Image{sources: sources_for_edit()} |> Images.change_image() render(conn, "new.html", title: "New Image", changeset: changeset) @@ -185,7 +186,7 @@ defmodule PhilomenaWeb.ImageController do _ in fragment("SELECT COUNT(*) FROM source_changes s WHERE s.image_id = ?", i.id), on: true ) - |> preload([:deleter, :locked_tags, user: [awards: :badge], tags: :aliases]) + |> preload([:deleter, :locked_tags, :sources, user: [awards: :badge], tags: :aliases]) |> select([i, t, s], {i, t.count, s.count}) |> Repo.one() |> case do @@ -217,4 +218,8 @@ defmodule PhilomenaWeb.ImageController do |> assign(:source_change_count, source_changes) end end + + defp sources_for_edit(), do: [%Source{}] + defp sources_for_edit([]), do: sources_for_edit() + defp sources_for_edit(sources), do: sources end diff --git a/lib/philomena_web/templates/image/_source.html.slime b/lib/philomena_web/templates/image/_source.html.slime index 87b09772..3264b2d4 100644 --- a/lib/philomena_web/templates/image/_source.html.slime +++ b/lib/philomena_web/templates/image/_source.html.slime @@ -1,14 +1,34 @@ .block + - has_sources = Enum.any?(@image.sources) = form_for @changeset, Routes.image_source_path(@conn, :update, @image), [method: "put", class: "hidden", id: "source-form", data: [remote: "true"]], fn f -> = if can?(@conn, :edit_metadata, @image) and !@conn.assigns.current_ban do = if @changeset.action do .alert.alert-danger p Oops, something went wrong! Please check the errors below. - - .flex - = url_input f, :source_url, id: "source-field", class: "input input--wide", autocomplete: "off", placeholder: "Source URL" - = submit "Save source", class: "button button--separate-left" + + p + 'The page(s) you found this image on. Images may have a maximum of + span.js-max-source-count> 10 + ' source URLs. Leave any sources you don't want to use blank. + + = inputs_for f, :sources, [as: "image[old_sources]", skip_hidden: true], fn fs -> + = hidden_input fs, :source + = inputs_for f, :sources, [skip_hidden: true], fn fs -> + .field.js-image-source.field--inline.flex--no-wrap.flex--centered + = text_input fs, :source, class: "input flex__grow js-source-url", placeholder: "Source URL" + = error_tag fs, :source + + label.input--separate-left.flex__fixed.flex--centered + a.js-source-remove href="#" + i.fa.fa-trash> + ' Delete + + .field + button.button.js-image-add-source type="button" + i.fa.fa-plus> + ' Add source + = submit "Save sources", class: "button button--separate-left" button.button.button--separate-left type="reset" data-click-hide="#source-form" data-click-show="#image-source" ' Cancel @@ -18,22 +38,15 @@ p ' You can't edit the source on this image. - .flex.flex--wrap#image-source - p - a.button.button--separate-right#edit-source data-click-focus="#source-field" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" + .flex.flex--wrap.flex--column#image-source + .flex + a.button.button--separate-right#edit-source data-click-focus=".js-image-source" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" i.fas.fa-edit> - ' Source: - - p - = if @image.source_url not in [nil, ""] do - a.js-source-link href=@image.source_url - strong - = @image.source_url - - - else - em> not provided yet - - = if @source_change_count > 1 do + = if !has_sources || length(@image.sources) == 1 do + ' Source: + - else + ' Sources: + = if @source_change_count > 0 do a.button.button--link.button--separate-left href=Routes.image_source_change_path(@conn, :index, @image) title="Source history" i.fa.fa-history> | History ( @@ -45,3 +58,22 @@ button.button.button--state-danger.button--separate-left type="submit" data-confirm="Are you really, really sure?" title="Wipe sources" i.fas.fa-eraser> ' Wipe + .image_sources + = if has_sources do + - [first_source | sources] = @image.sources + .image_source__icon + i class=image_source_icon(first_source.source) + .image_source__link + a.js-source-link href=first_source.source + strong = first_source.source + = for source <- sources do + .image_source__icon + i class=image_source_icon(source.source) + .image_source__link + a href=source.source + strong = source.source + - else + .image_source__icon + i.fa.fa-unlink + .image_source__link + em> not provided yet diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime index e9db71c4..0f2a98be 100644 --- a/lib/philomena_web/templates/image/new.html.slime +++ b/lib/philomena_web/templates/image/new.html.slime @@ -38,9 +38,25 @@ .field-error-js.hidden.js-scraper h4 About this image + p + 'The page(s) you found this image on. Images may have a maximum of + span.js-max-source-count> 10 + ' source URLs. Leave any sources you don't want to use blank. + + = inputs_for f, :sources, fn fs -> + .field.js-image-source.field--inline.flex--no-wrap.flex--centered + = text_input fs, :source, class: "input flex__grow js-source-url", placeholder: "Source URL" + = error_tag fs, :source + + label.input--separate-left.flex__fixed.flex--centered + a.js-source-remove href="#" + i.fa.fa-trash> + ' Delete + .field - = label f, :source_url, "The page you found this image on" - = url_input f, :source_url, class: "input input--wide js-image-input", placeholder: "Source URL" + button.button.js-image-add-source type="button" + i.fa.fa-plus> + ' Add source .field label for="image[tag_input]" @@ -65,7 +81,7 @@ .field .block .communication-edit__wrap - = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, action_icon: "pencil-alt", action_text: "Description", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source.", name: :description, class: "js-image-input", required: false + = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, action_icon: "pencil-alt", action_text: "Description", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source.", name: :description, class: "js-image-descr-input", required: false = render PhilomenaWeb.MarkdownView, "_anon_checkbox.html", conn: @conn, f: f, label: "Post anonymously" diff --git a/lib/philomena_web/templates/source_change/index.html.slime b/lib/philomena_web/templates/source_change/index.html.slime index 77663b20..05971a76 100644 --- a/lib/philomena_web/templates/source_change/index.html.slime +++ b/lib/philomena_web/templates/source_change/index.html.slime @@ -7,10 +7,10 @@ thead tr th colspan=2 Image - th New Source + th Source + th Action th Timestamp th User - th Initial? tbody = for source_change <- @source_changes do @@ -21,8 +21,13 @@ = render PhilomenaWeb.ImageView, "_image_container.html", image: source_change.image, size: :thumb_tiny, conn: @conn td - = source_change.new_value - + = source_change.source_url + + = if source_change.added do + td.success Added + - else + td.danger Removed + td = pretty_time(source_change.created_at) @@ -41,9 +46,5 @@ br ' Ask them before reverting their changes. - td - = if source_change.initial do - ' ✓ - .block__header = @pagination diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime index 3f4d43db..0bb692fe 100644 --- a/lib/philomena_web/templates/tag/_tag_editor.html.slime +++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime @@ -1,6 +1,6 @@ elixir: textarea_options = [ - class: "input input--wide tagsinput js-image-input js-taginput js-taginput-plain js-taginput-#{@name}", + class: "input input--wide tagsinput js-image-tags-input js-taginput js-taginput-plain js-taginput-#{@name}", placeholder: "Add tags separated with commas", autocomplete: "off" ] diff --git a/lib/philomena_web/views/duplicate_report_view.ex b/lib/philomena_web/views/duplicate_report_view.ex index 34192974..f53b333f 100644 --- a/lib/philomena_web/views/duplicate_report_view.ex +++ b/lib/philomena_web/views/duplicate_report_view.ex @@ -58,19 +58,24 @@ defmodule PhilomenaWeb.DuplicateReportView do do: abs(duplicate_of_image.image_aspect_ratio - image.image_aspect_ratio) <= 0.009 def neither_have_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: blank?(duplicate_of_image.source_url) and blank?(image.source_url) + do: Enum.empty?(duplicate_of_image.sources) and Enum.empty?(image.sources) - def same_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: to_string(duplicate_of_image.source_url) == to_string(image.source_url) + def same_source?(%{image: image, duplicate_of_image: duplicate_of_image}) do + MapSet.equal?(MapSet.new(image.sources), MapSet.new(duplicate_of_image.sources)) + end - def similar_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: uri_host(image.source_url) == uri_host(duplicate_of_image.source_url) + def similar_source?(%{image: image, duplicate_of_image: duplicate_of_image}) do + MapSet.equal?( + MapSet.new(image.sources, &URI.parse(&1.source).host), + MapSet.new(duplicate_of_image.sources, &URI.parse(&1.source).host) + ) + end def source_on_target?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: present?(duplicate_of_image.source_url) and blank?(image.source_url) + do: Enum.any?(duplicate_of_image.sources) and Enum.empty?(image.sources) def source_on_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: blank?(duplicate_of_image.source_url) && present?(image.source_url) + do: Enum.empty?(duplicate_of_image.sources) && Enum.any?(image.sources) def same_artist_tags?(%{image: image, duplicate_of_image: duplicate_of_image}), do: MapSet.equal?(artist_tags(image), artist_tags(duplicate_of_image)) diff --git a/lib/philomena_web/views/image_view.ex b/lib/philomena_web/views/image_view.ex index f6368458..006c7aec 100644 --- a/lib/philomena_web/views/image_view.ex +++ b/lib/philomena_web/views/image_view.ex @@ -287,4 +287,96 @@ defmodule PhilomenaWeb.ImageView do Philomena.Search.Evaluator.hits?(doc, query) end + + def image_source_icon(nil), do: "fa fa-link" + def image_source_icon(""), do: "fa fa-link" + + def image_source_icon(source) do + site_domains = + String.split(Application.get_env(:philomena, :site_domains), ",") ++ + [Application.get_env(:philomena, :cdn_host)] + + uri = URI.parse(source) + + case uri.host do + u when u in ["twitter.com", "www.twitter.com", "pbs.twimg.com", "twimg.com"] -> + "fab fa-twitter" + + u when u in ["deviantart.com", "www.deviantart.com", "sta.sh", "www.sta.sh"] -> + "fab fa-deviantart" + + u when u in ["cdn.discordapp.com", "discordapp.com", "discord.com"] -> + "fab fa-discord" + + u when u in ["youtube.com", "www.youtube.com"] -> + "fab fa-youtube" + + u when u in ["pillowfort.social", "www.pillowfort.social"] -> + "fa fa-bed" + + u when u in ["vk.com", "vk.ru"] -> + "fab fa-vk" + + u when u in ["pixiv.net", "www.pixiv.net", "artfight.net", "www.artfight.net"] -> + "fa fa-paintbrush" + + u when u in ["patreon.com", "www.patreon.com"] -> + "fab fa-patreon" + + u when u in ["ych.art", "ych.commishes.com", "commishes.com"] -> + "fa fa-palette" + + u when u in ["artstation.com", "www.artstation.com"] -> + "fab fa-artstation" + + u when u in ["instagram.com", "www.instagram.com"] -> + "fab fa-instagram" + + u when u in ["reddit.com", "www.reddit.com"] -> + "fab fa-reddit" + + u when u in ["facebook.com", "www.facebook.com", "fb.me", "www.fb.me"] -> + "fab fa-facebook" + + u when u in ["tiktok.com", "www.tiktok.com"] -> + "fab fa-tiktok" + + u + when u in [ + "furaffinity.net", + "www.furaffinity.net", + "furbooru.org", + "inkbunny.net", + "e621.net", + "e926.net" + ] -> + "fa fa-paw" + + u + when u in [ + "awoo.space", + "bark.lgbt", + "equestria.social", + "foxy.social", + "mastodon.art", + "mastodon.social", + "meow.social", + "pawoo.net", + "pettingzoo.co", + "pony.social", + "vulpine.club", + "yiff.life" + ] -> + "fab fa-mastodon" + + link -> + cond do + Enum.member?(site_domains, link) -> "favicon-home" + String.contains?(link, "tumblr") -> "fab fa-tumblr" + String.contains?(link, "deviantart") -> "fab fa-deviantart" + String.contains?(link, "sofurry") -> "fa fa-paw" + true -> "fa fa-link" + end + end + end end diff --git a/priv/repo/migrations/20211009011024_rewrite_source_changes.exs b/priv/repo/migrations/20211009011024_rewrite_source_changes.exs new file mode 100644 index 00000000..030e9574 --- /dev/null +++ b/priv/repo/migrations/20211009011024_rewrite_source_changes.exs @@ -0,0 +1,141 @@ +defmodule Philomena.Repo.Migrations.RewriteSourceChanges do + use Ecto.Migration + + def up do + rename table(:source_changes), to: table(:old_source_changes) + + execute( + "alter index index_source_changes_on_image_id rename to index_old_source_changes_on_image_id" + ) + + execute( + "alter index index_source_changes_on_user_id rename to index_old_source_changes_on_user_id" + ) + + execute("alter index index_source_changes_on_ip rename to index_old_source_changes_on_ip") + + execute( + "alter table old_source_changes rename constraint source_changes_pkey to old_source_changes_pkey" + ) + + execute("alter sequence source_changes_id_seq rename to old_source_changes_id_seq") + + create table(:source_changes) do + add :image_id, references(:images, on_update: :update_all, on_delete: :delete_all), + null: false + + add :user_id, references(:users, on_update: :update_all, on_delete: :delete_all) + add :ip, :inet, null: false + timestamps(inserted_at: :created_at) + + add :added, :boolean, null: false + add :fingerprint, :string + add :user_agent, :string, default: "" + add :referrer, :string, default: "" + add :value, :string, null: false + end + + alter table(:image_sources) do + remove :id + modify :source, :string + end + + create index(:image_sources, [:image_id, :source], + name: "index_image_source_on_image_id_and_source", + unique: true + ) + + drop constraint(:image_sources, :length_must_be_valid, + check: "length(source) >= 8 and length(source) <= 1024" + ) + + create constraint(:image_sources, :image_sources_source_check, check: "source ~* '^https?://'") + + # These statements should not be ran by the migration in production. + # Run them manually in psql instead. + if System.get_env("MIX_ENV") != "prod" do + execute(""" + insert into image_sources (image_id, source) + select id as image_id, substr(source_url, 1, 255) as source from images + where source_url is not null and source_url ~* '^https?://'; + """) + + # First insert the "added" changes... + execute(""" + with ranked_added_source_changes as ( + select + image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, + substr(referrer, 1, 255) as referrer, + substr(new_value, 1, 255) as value, true as added, + rank() over (partition by image_id order by created_at asc) + from old_source_changes + where new_value is not null + ) + insert into source_changes + (image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, referrer, value, added) + select image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, referrer, value, added + from ranked_added_source_changes + where "rank" > 1; + """) + + # ...then the "removed" changes + execute(""" + with ranked_removed_source_changes as ( + select + image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, + substr(referrer, 1, 255) as referrer, + substr(new_value, 1, 255) as value, false as added, + rank() over (partition by image_id order by created_at desc) + from old_source_changes + where new_value is not null + ) + insert into source_changes + (image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, referrer, value, added) + select image_id, user_id, ip, created_at, updated_at, fingerprint, user_agent, referrer, value, added + from ranked_removed_source_changes + where "rank" > 1; + """) + end + + create index(:source_changes, [:image_id], name: "index_source_changes_on_image_id") + create index(:source_changes, [:user_id], name: "index_source_changes_on_user_id") + create index(:source_changes, [:ip], name: "index_source_changes_on_ip") + end + + def down do + drop table(:source_changes) + rename table(:old_source_changes), to: table(:source_changes) + + execute( + "alter index index_old_source_changes_on_image_id rename to index_source_changes_on_image_id" + ) + + execute( + "alter index index_old_source_changes_on_user_id rename to index_source_changes_on_user_id" + ) + + execute("alter index index_old_source_changes_on_ip rename to index_source_changes_on_ip") + + execute( + "alter table source_changes rename constraint old_source_changes_pkey to source_changes_pkey" + ) + + execute("alter sequence old_source_changes_id_seq rename to source_changes_id_seq") + + execute("truncate image_sources") + + drop constraint(:image_sources, :image_sources_source_check, check: "source ~* '^https?://'") + + create constraint(:image_sources, :length_must_be_valid, + check: "length(source) >= 8 and length(source) <= 1024" + ) + + drop index(:image_sources, [:image_id, :source], + name: "index_image_source_on_image_id_and_source" + ) + + alter table(:image_sources) do + modify :source, :text + end + end +end diff --git a/priv/repo/seeds_development.json b/priv/repo/seeds_development.json index a31b420a..47625a51 100644 --- a/priv/repo/seeds_development.json +++ b/priv/repo/seeds_development.json @@ -18,35 +18,48 @@ "role": "user" } ], - "remote_images": [{ + "remote_images": [ + { "url": "https://derpicdn.net/img/2015/9/26/988000/thumb.gif", - "source_url": "https://derpibooru.org/988000", + "sources": [ + "https://derpibooru.org/988000" + ], "description": "Fairly large GIF (~23MB), use to test WebM stuff.", "tag_input": "alicorn, angry, animated, art, artist:assasinmonkey, artist:equum_amici, badass, barrier, crying, dark, epic, female, fight, force field, glare, glow, good vs evil, lord tirek, low angle, magic, mare, messy mane, metal as fuck, perspective, plot, pony, raised hoof, safe, size difference, spread wings, stomping, twilight's kingdom, twilight sparkle, twilight sparkle (alicorn), twilight vs tirek, underhoof" }, { "url": "https://derpicdn.net/img/2012/1/2/25/large.png", - "source_url": "https://derpibooru.org/25", + "sources": [ + "https://derpibooru.org/25" + ], "tag_input": "artist:moe, canterlot, castle, cliff, cloud, detailed background, fog, forest, grass, mountain, mountain range, nature, no pony, outdoors, path, river, safe, scenery, scenery porn, signature, source needed, sunset, technical advanced, town, tree, useless source url, water, waterfall, widescreen, wood" }, { "url": "https://derpicdn.net/img/2018/6/28/1767886/full.webm", - "source_url": "http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309", + "sources": [ + "http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309" + ], "tag_input": "3d, animated, architecture, artist:hydrusbeta, castle, cloud, crystal empire, crystal palace, flag, flag waving, no pony, no sound, safe, scenery, webm" }, { "url": "https://derpicdn.net/img/view/2015/2/19/832750.jpg", - "source_url": "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the", + "sources": [ + "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the" + ], "tag_input": "artist:rhads, artist:the sexy assistant, canterlot, cloud, cloudsdale, cloudy, edit, lens flare, no pony, ponyville, rainbow, river, safe, scenery, sweet apple acres" }, { "url": "https://derpicdn.net/img/view/2016/3/17/1110529.jpg", - "source_url": "https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926", + "sources": [ + "https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926" + ], "tag_input": "artist:devinian, aurora crystialis, bridge, cloud, crepuscular rays, crystal empire, crystal palace, edit, flower, forest, grass, log, mountain, no pony, river, road, safe, scenery, scenery porn, source needed, stars, sunset, swing, tree, wallpaper" }, { "url": "https://derpicdn.net/img/view/2019/6/16/2067468.svg", - "source_url": "https://derpibooru.org/2067468", + "sources": [ + "https://derpibooru.org/2067468" + ], "tag_input": "artist:cheezedoodle96, babs seed, bloom and gloom, cutie mark, cutie mark only, no pony, safe, scissors, simple background, svg, .svg available, transparent background, vector" } ], diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 1a412576..ab137a7c 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -842,32 +842,12 @@ ALTER SEQUENCE public.image_intensities_id_seq OWNED BY public.image_intensities -- CREATE TABLE public.image_sources ( - id bigint NOT NULL, image_id bigint NOT NULL, - source text NOT NULL, - CONSTRAINT length_must_be_valid CHECK (((length(source) >= 8) AND (length(source) <= 1024))) + source character varying(255) NOT NULL, + CONSTRAINT image_sources_source_check CHECK (((source)::text ~* '^https?://'::text)) ); --- --- Name: image_sources_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.image_sources_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: image_sources_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.image_sources_id_seq OWNED BY public.image_sources.id; - - -- -- Name: image_subscriptions; Type: TABLE; Schema: public; Owner: - -- @@ -1136,6 +1116,44 @@ CREATE SEQUENCE public.notifications_id_seq ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id; +-- +-- Name: old_source_changes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.old_source_changes ( + id integer NOT NULL, + ip inet NOT NULL, + fingerprint character varying, + user_agent character varying DEFAULT ''::character varying, + referrer character varying DEFAULT ''::character varying, + new_value character varying, + initial boolean DEFAULT false NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + user_id integer, + image_id integer NOT NULL +); + + +-- +-- Name: old_source_changes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.old_source_changes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: old_source_changes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.old_source_changes_id_seq OWNED BY public.old_source_changes.id; + + -- -- Name: poll_options; Type: TABLE; Schema: public; Owner: - -- @@ -1414,17 +1432,17 @@ ALTER SEQUENCE public.site_notices_id_seq OWNED BY public.site_notices.id; -- CREATE TABLE public.source_changes ( - id integer NOT NULL, + id bigint NOT NULL, + image_id bigint NOT NULL, + user_id bigint, ip inet NOT NULL, - fingerprint character varying, - user_agent character varying DEFAULT ''::character varying, - referrer character varying DEFAULT ''::character varying, - new_value character varying, - initial boolean DEFAULT false NOT NULL, - created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL, - user_id integer, - image_id integer NOT NULL + created_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + added boolean NOT NULL, + fingerprint character varying(255), + user_agent character varying(255) DEFAULT ''::character varying, + referrer character varying(255) DEFAULT ''::character varying, + value character varying(255) NOT NULL ); @@ -2265,13 +2283,6 @@ ALTER TABLE ONLY public.image_features ALTER COLUMN id SET DEFAULT nextval('publ ALTER TABLE ONLY public.image_intensities ALTER COLUMN id SET DEFAULT nextval('public.image_intensities_id_seq'::regclass); --- --- Name: image_sources id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.image_sources ALTER COLUMN id SET DEFAULT nextval('public.image_sources_id_seq'::regclass); - - -- -- Name: images id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2307,6 +2318,13 @@ ALTER TABLE ONLY public.moderation_logs ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); +-- +-- Name: old_source_changes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.old_source_changes ALTER COLUMN id SET DEFAULT nextval('public.old_source_changes_id_seq'::regclass); + + -- -- Name: poll_options id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2627,14 +2645,6 @@ ALTER TABLE ONLY public.image_intensities ADD CONSTRAINT image_intensities_pkey PRIMARY KEY (id); --- --- Name: image_sources image_sources_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.image_sources - ADD CONSTRAINT image_sources_pkey PRIMARY KEY (id); - - -- -- Name: images images_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2675,6 +2685,14 @@ ALTER TABLE ONLY public.notifications ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); +-- +-- Name: old_source_changes old_source_changes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.old_source_changes + ADD CONSTRAINT old_source_changes_pkey PRIMARY KEY (id); + + -- -- Name: poll_options poll_options_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3407,6 +3425,13 @@ CREATE INDEX index_image_hides_on_user_id ON public.image_hides USING btree (use CREATE UNIQUE INDEX index_image_intensities_on_image_id ON public.image_intensities USING btree (image_id); +-- +-- Name: index_image_source_on_image_id_and_source; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_image_source_on_image_id_and_source ON public.image_sources USING btree (image_id, source); + + -- -- Name: index_image_subscriptions_on_image_id_and_user_id; Type: INDEX; Schema: public; Owner: - -- @@ -3540,6 +3565,27 @@ CREATE INDEX index_mod_notes_on_notable_type_and_notable_id ON public.mod_notes CREATE UNIQUE INDEX index_notifications_on_actor_id_and_actor_type ON public.notifications USING btree (actor_id, actor_type); +-- +-- Name: index_old_source_changes_on_image_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_old_source_changes_on_image_id ON public.old_source_changes USING btree (image_id); + + +-- +-- Name: index_old_source_changes_on_ip; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_old_source_changes_on_ip ON public.old_source_changes USING btree (ip); + + +-- +-- Name: index_old_source_changes_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_old_source_changes_on_user_id ON public.old_source_changes USING btree (user_id); + + -- -- Name: index_poll_options_on_poll_id_and_label; Type: INDEX; Schema: public; Owner: - -- @@ -4177,10 +4223,10 @@ ALTER TABLE ONLY public.image_taggings -- --- Name: source_changes fk_rails_10271ec4d0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: old_source_changes fk_rails_10271ec4d0; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.source_changes +ALTER TABLE ONLY public.old_source_changes ADD CONSTRAINT fk_rails_10271ec4d0 FOREIGN KEY (image_id) REFERENCES public.images(id) ON UPDATE CASCADE ON DELETE CASCADE; @@ -4577,10 +4623,10 @@ ALTER TABLE ONLY public.polls -- --- Name: source_changes fk_rails_8d8cb9cb3b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: old_source_changes fk_rails_8d8cb9cb3b; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.source_changes +ALTER TABLE ONLY public.old_source_changes ADD CONSTRAINT fk_rails_8d8cb9cb3b FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE SET NULL; @@ -4950,6 +4996,19 @@ 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: - +-- + +ALTER TABLE ONLY public.source_changes + ADD CONSTRAINT source_changes_image_id_fkey FOREIGN KEY (image_id) REFERENCES public.images(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: source_changes source_changes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.source_changes + ADD CONSTRAINT source_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE; -- @@ -4988,6 +5047,7 @@ INSERT INTO public."schema_migrations" (version) VALUES (20210912171343); INSERT INTO public."schema_migrations" (version) VALUES (20210917190346); INSERT INTO public."schema_migrations" (version) VALUES (20210921025336); INSERT INTO public."schema_migrations" (version) VALUES (20210929181319); +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);