Merge pull request #143 from philomena-dev/sources

Multiple sources
This commit is contained in:
Meow 2023-05-29 11:16:32 +02:00 committed by GitHub
commit a569008267
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 843 additions and 176 deletions

View file

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

View file

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

View file

@ -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',
});
}

12
assets/js/sources.js Normal file
View file

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

View file

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

View file

@ -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');

View file

@ -1,5 +1,7 @@
// DOM Utils
type PhilomenaInputElements = HTMLTextAreaElement | HTMLInputElement | HTMLButtonElement;
/**
* Get the first matching element
*/
@ -34,6 +36,18 @@ export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>
});
}
export function disableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
el.disabled = true;
});
}
export function enableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
el.disabled = false;
});
}
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el));
}

View file

@ -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();
});

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
.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
@ -6,9 +7,28 @@
.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

View file

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

View file

@ -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,7 +21,12 @@
= 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
' &#x2713;
.block__header
= @pagination

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
}
],

View file

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