Merge pull request #406 from philomena-dev/docs

Add doc comments to context modules
This commit is contained in:
liamwhite 2025-02-06 22:03:01 -05:00 committed by GitHub
commit d6e360a3a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1586 additions and 33 deletions

View file

@ -119,6 +119,20 @@ defmodule Philomena.Comments do
Repo.delete(comment) Repo.delete(comment)
end end
@doc """
Hides a comment and handles associated reports.
## Parameters
- comment: The comment to hide
- attrs: Attributes for the hide operation
- user: The user performing the hide action
## Examples
iex> hide_comment(comment, %{staff_note: "Rule violation"}, user)
{:ok, %Comment{}}
"""
def hide_comment(%Comment{} = comment, attrs, user) do def hide_comment(%Comment{} = comment, attrs, user) do
report_query = Reports.close_report_query({"Comment", comment.id}, user) report_query = Reports.close_report_query({"Comment", comment.id}, user)
comment = Comment.hide_changeset(comment, attrs, user) comment = Comment.hide_changeset(comment, attrs, user)
@ -139,6 +153,15 @@ defmodule Philomena.Comments do
end end
end end
@doc """
Unhides a previously hidden comment.
## Examples
iex> unhide_comment(comment)
{:ok, %Comment{}}
"""
def unhide_comment(%Comment{} = comment) do def unhide_comment(%Comment{} = comment) do
comment comment
|> Comment.unhide_changeset() |> Comment.unhide_changeset()
@ -154,12 +177,35 @@ defmodule Philomena.Comments do
end end
end end
@doc """
Marks a comment as destroyed and removes its text (hard deletion).
## Examples
iex> destroy_comment(comment)
{:ok, %Comment{}}
"""
def destroy_comment(%Comment{} = comment) do def destroy_comment(%Comment{} = comment) do
comment comment
|> Comment.destroy_changeset() |> Comment.destroy_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Approves a comment, closes associated reports, and increments the user comments
posted count.
## Parameters
- comment: The comment to approve
- user: The user performing the approval
## Examples
iex> approve_comment(comment, user)
{:ok, %Comment{}}
"""
def approve_comment(%Comment{} = comment, user) do def approve_comment(%Comment{} = comment, user) do
report_query = Reports.close_report_query({"Comment", comment.id}, user) report_query = Reports.close_report_query({"Comment", comment.id}, user)
comment = Comment.approve_changeset(comment) comment = Comment.approve_changeset(comment)
@ -181,6 +227,23 @@ defmodule Philomena.Comments do
end end
end end
@doc """
Creates a system report for non-approved comments containing external images.
Returns false for already approved comments.
## Returns
- `false`: If the comment is already approved
- `{:ok, %Report{}}`: If a system report was created
## Examples
iex> report_non_approved(approved_comment)
false
iex> report_non_approved(unapproved_comment)
{:ok, %Report{}}
"""
def report_non_approved(%Comment{approved: true}), do: false def report_non_approved(%Comment{approved: true}), do: false
def report_non_approved(comment) do def report_non_approved(comment) do
@ -191,6 +254,20 @@ defmodule Philomena.Comments do
) )
end end
@doc """
Migrates comments from one image to another when handling duplicate images.
Returns the duplicate image parameter unchanged, for use in a pipeline.
## Parameters
- image: The source image whose comments will be moved
- duplicate_of_image: The target image that will receive the comments
## Examples
iex> migrate_comments(source_image, target_image)
%Image{}
"""
def migrate_comments(image, duplicate_of_image) do def migrate_comments(image, duplicate_of_image) do
{count, nil} = {count, nil} =
Comment Comment
@ -217,24 +294,62 @@ defmodule Philomena.Comments do
Comment.changeset(comment, %{}) Comment.changeset(comment, %{})
end end
@doc """
Updates comment search indices when a user's name changes.
## Examples
iex> user_name_reindex("old_username", "new_username")
:ok
"""
def user_name_reindex(old_name, new_name) do def user_name_reindex(old_name, new_name) do
data = CommentIndex.user_name_update_by_query(old_name, new_name) data = CommentIndex.user_name_update_by_query(old_name, new_name)
Search.update_by_query(Comment, data.query, data.set_replacements, data.replacements) Search.update_by_query(Comment, data.query, data.set_replacements, data.replacements)
end end
@doc """
Queues a single comment for search index updates.
Returns the comment struct unchanged, for use in a pipeline.
## Examples
iex> reindex_comment(comment)
%Comment{}
"""
def reindex_comment(%Comment{} = comment) do def reindex_comment(%Comment{} = comment) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "id", [comment.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "id", [comment.id]])
comment comment
end end
@doc """
Queues all comments associated with an image for search index updates.
Returns the image struct unchanged, for use in a pipeline.
## Examples
iex> reindex_comments(image)
%Image{}
"""
def reindex_comments(image) do def reindex_comments(image) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "image_id", [image.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "image_id", [image.id]])
image image
end end
@doc """
Provides preload queries for comment indexing operations.
## Examples
iex> indexing_preloads()
[user: user_query, image: image_query]
"""
def indexing_preloads do def indexing_preloads do
user_query = select(User, [u], map(u, [:id, :name])) user_query = select(User, [u], map(u, [:id, :name]))
tag_query = select(Tag, [t], map(t, [:id])) tag_query = select(Tag, [t], map(t, [:id]))
@ -250,6 +365,22 @@ defmodule Philomena.Comments do
] ]
end end
@doc """
Performs a search reindex operation on comments matching the given criteria.
## Parameters
- column: The database column to filter on (e.g., :id, :image_id)
- condition: A list of values to match against the column
## Examples
iex> perform_reindex(:id, [1, 2, 3])
:ok
iex> perform_reindex(:image_id, [123])
:ok
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Comment Comment
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())

View file

@ -77,6 +77,18 @@ defmodule Philomena.DnpEntries do
|> Repo.update() |> Repo.update()
end end
@doc """
Transitions a DNP entry to a new state.
## Examples
iex> transition_dnp_entry(dnp_entry, user, "acknowledged")
{:ok, %DnpEntry{}}
iex> transition_dnp_entry(dnp_entry, user, "invalid_state")
{:error, %Ecto.Changeset{}}
"""
def transition_dnp_entry(%DnpEntry{} = dnp_entry, user, new_state) do def transition_dnp_entry(%DnpEntry{} = dnp_entry, user, new_state) do
dnp_entry dnp_entry
|> DnpEntry.transition_changeset(user, new_state) |> DnpEntry.transition_changeset(user, new_state)
@ -112,6 +124,19 @@ defmodule Philomena.DnpEntries do
DnpEntry.changeset(dnp_entry, %{}) DnpEntry.changeset(dnp_entry, %{})
end end
@doc """
Returns the count of active DNP entries in requested, claimed,
or acknowledged state, if the user has permission to view them.
## Examples
iex> count_dnp_entries(admin)
42
iex> count_dnp_entries(user)
nil
"""
def count_dnp_entries(user) do def count_dnp_entries(user) do
if Canada.Can.can?(user, :index, DnpEntry) do if Canada.Can.can?(user, :index, DnpEntry) do
DnpEntry DnpEntry

View file

@ -16,6 +16,18 @@ defmodule Philomena.DuplicateReports do
alias Philomena.Images.Image alias Philomena.Images.Image
alias Philomena.Images alias Philomena.Images
@doc """
Generates automated duplicate reports for an image based on perceptual matching.
Takes a source image and generates duplicate reports for similar images based on
intensity and aspect ratio comparison.
## Examples
iex> generate_reports(source_image)
[{:ok, %DuplicateReport{}}, ...]
"""
def generate_reports(source) do def generate_reports(source) do
source = Repo.preload(source, :intensity) source = Repo.preload(source, :intensity)
@ -30,6 +42,23 @@ defmodule Philomena.DuplicateReports do
end) end)
end end
@doc """
Query for potential duplicate images based on intensity values and aspect ratio.
Takes a tuple of {intensities, aspect_ratio} and optional options to control the search:
- `:aspect_dist` - Maximum aspect ratio difference (default: 0.05)
- `:limit` - Maximum number of results (default: 10)
- `:dist` - Maximum intensity difference per channel (default: 0.25)
## Examples
iex> find_duplicates({%{nw: 0.5, ne: 0.5, sw: 0.5, se: 0.5}, 1.0})
#Ecto.Query<...>
iex> find_duplicates({intensities, ratio}, dist: 0.3, limit: 20)
#Ecto.Query<...>
"""
def find_duplicates({intensities, aspect_ratio}, opts \\ []) do def find_duplicates({intensities, aspect_ratio}, opts \\ []) do
aspect_dist = Keyword.get(opts, :aspect_dist, 0.05) aspect_dist = Keyword.get(opts, :aspect_dist, 0.05)
limit = Keyword.get(opts, :limit, 10) limit = Keyword.get(opts, :limit, 10)
@ -150,6 +179,21 @@ defmodule Philomena.DuplicateReports do
|> Repo.insert() |> Repo.insert()
end end
@doc """
Accepts a duplicate report and merges the duplicate image into the target image.
Takes an optional Ecto.Multi, the duplicate report to accept, and the user accepting the report.
Handles rejecting any other duplicate reports between the same images and merges the images.
## Examples
iex> accept_duplicate_report(nil, duplicate_report, user)
{:ok, %{duplicate_report: %DuplicateReport{}, ...}}
iex> accept_duplicate_report(existing_multi, duplicate_report, user)
%Ecto.Multi{}
"""
def accept_duplicate_report(multi \\ nil, %DuplicateReport{} = duplicate_report, user) do def accept_duplicate_report(multi \\ nil, %DuplicateReport{} = duplicate_report, user) do
duplicate_report = Repo.preload(duplicate_report, [:image, :duplicate_of_image]) duplicate_report = Repo.preload(duplicate_report, [:image, :duplicate_of_image])
@ -175,6 +219,18 @@ defmodule Philomena.DuplicateReports do
|> Images.merge_image(duplicate_report.image, duplicate_report.duplicate_of_image, user) |> Images.merge_image(duplicate_report.image, duplicate_report.duplicate_of_image, user)
end end
@doc """
Accepts a duplicate report in reverse, making the target image the duplicate instead.
Creates a new duplicate report with reversed image relationship if one doesn't exist,
rejects the original report, and accepts the reversed report.
## Examples
iex> accept_reverse_duplicate_report(duplicate_report, user)
{:ok, %{duplicate_report: %DuplicateReport{}, ...}}
"""
def accept_reverse_duplicate_report(%DuplicateReport{} = duplicate_report, user) do def accept_reverse_duplicate_report(%DuplicateReport{} = duplicate_report, user) do
new_report = new_report =
DuplicateReport DuplicateReport
@ -204,18 +260,47 @@ defmodule Philomena.DuplicateReports do
|> accept_duplicate_report(new_report, user) |> accept_duplicate_report(new_report, user)
end end
@doc """
Claims a duplicate report for review by a user.
## Examples
iex> claim_duplicate_report(duplicate_report, user)
{:ok, %DuplicateReport{}}
"""
def claim_duplicate_report(%DuplicateReport{} = duplicate_report, user) do def claim_duplicate_report(%DuplicateReport{} = duplicate_report, user) do
duplicate_report duplicate_report
|> DuplicateReport.claim_changeset(user) |> DuplicateReport.claim_changeset(user)
|> Repo.update() |> Repo.update()
end end
@doc """
Removes a user's claim on a duplicate report.
## Examples
iex> unclaim_duplicate_report(duplicate_report)
{:ok, %DuplicateReport{}}
"""
def unclaim_duplicate_report(%DuplicateReport{} = duplicate_report) do def unclaim_duplicate_report(%DuplicateReport{} = duplicate_report) do
duplicate_report duplicate_report
|> DuplicateReport.unclaim_changeset() |> DuplicateReport.unclaim_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Rejects a duplicate report.
Updates the duplicate report's state to rejected and records the user who rejected it.
## Examples
iex> reject_duplicate_report(duplicate_report, user)
{:ok, %DuplicateReport{}}
"""
def reject_duplicate_report(%DuplicateReport{} = duplicate_report, user) do def reject_duplicate_report(%DuplicateReport{} = duplicate_report, user) do
duplicate_report duplicate_report
|> DuplicateReport.reject_changeset(user) |> DuplicateReport.reject_changeset(user)
@ -251,6 +336,19 @@ defmodule Philomena.DuplicateReports do
DuplicateReport.changeset(duplicate_report, %{}) DuplicateReport.changeset(duplicate_report, %{})
end end
@doc """
Counts the number of duplicate reports in "open" state,
if the user has permission to view them.
## Examples
iex> count_duplicate_reports(admin)
42
iex> count_duplicate_reports(user)
nil
"""
def count_duplicate_reports(user) do def count_duplicate_reports(user) do
if Canada.Can.can?(user, :index, DuplicateReport) do if Canada.Can.can?(user, :index, DuplicateReport) do
DuplicateReport DuplicateReport

View file

@ -93,6 +93,17 @@ defmodule Philomena.Filters do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Makes a filter public.
Updates the filter to be publicly accessible by other users.
## Examples
iex> make_filter_public(filter)
{:ok, %Filter{}}
"""
def make_filter_public(%Filter{} = filter) do def make_filter_public(%Filter{} = filter) do
filter filter
|> Filter.public_changeset() |> Filter.public_changeset()
@ -140,6 +151,21 @@ defmodule Philomena.Filters do
Filter.changeset(filter, %{}) Filter.changeset(filter, %{})
end end
@doc """
Returns a grouped list of recent and user filters.
Takes a user and returns a list of their recently used filters and personal filters,
grouped into "Recent Filters" and "Your Filters" categories.
## Examples
iex> recent_and_user_filters(user)
[
{"Recent Filters", [[key: "Filter 1", value: 1], ...]},
{"Your Filters", [[key: "Filter 2", value: 2], ...]}
]
"""
def recent_and_user_filters(user) do def recent_and_user_filters(user) do
recent_filter_ids = recent_filter_ids =
[user.current_filter_id | user.recent_filter_ids] [user.current_filter_id | user.recent_filter_ids]
@ -174,6 +200,17 @@ defmodule Philomena.Filters do
|> Enum.reverse() |> Enum.reverse()
end end
@doc """
Adds a tag to a filter's hidden tags list.
Updates the filter to hide content with the specified tag.
## Examples
iex> hide_tag(filter, tag)
{:ok, %Filter{}}
"""
def hide_tag(filter, tag) do def hide_tag(filter, tag) do
hidden_tag_ids = Enum.uniq([tag.id | filter.hidden_tag_ids]) hidden_tag_ids = Enum.uniq([tag.id | filter.hidden_tag_ids])
@ -183,6 +220,15 @@ defmodule Philomena.Filters do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Removes a tag from a filter's hidden tags list.
## Examples
iex> unhide_tag(filter, tag)
{:ok, %Filter{}}
"""
def unhide_tag(filter, tag) do def unhide_tag(filter, tag) do
hidden_tag_ids = filter.hidden_tag_ids -- [tag.id] hidden_tag_ids = filter.hidden_tag_ids -- [tag.id]
@ -192,6 +238,15 @@ defmodule Philomena.Filters do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Adds a tag to a filter's spoilered tags list.
## Examples
iex> spoiler_tag(filter, tag)
{:ok, %Filter{}}
"""
def spoiler_tag(filter, tag) do def spoiler_tag(filter, tag) do
spoilered_tag_ids = Enum.uniq([tag.id | filter.spoilered_tag_ids]) spoilered_tag_ids = Enum.uniq([tag.id | filter.spoilered_tag_ids])
@ -201,6 +256,15 @@ defmodule Philomena.Filters do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Removes a tag from a filter's spoilered tags list.
## Examples
iex> unspoiler_tag(filter, tag)
{:ok, %Filter{}}
"""
def unspoiler_tag(filter, tag) do def unspoiler_tag(filter, tag) do
spoilered_tag_ids = filter.spoilered_tag_ids -- [tag.id] spoilered_tag_ids = filter.spoilered_tag_ids -- [tag.id]
@ -210,38 +274,92 @@ defmodule Philomena.Filters do
|> reindex_after_update() |> reindex_after_update()
end end
defp reindex_after_update({:ok, filter}) do defp reindex_after_update(result) do
reindex_filter(filter) case result do
{:ok, filter} ->
reindex_filter(filter)
{:ok, filter} {:ok, filter}
error ->
error
end
end end
defp reindex_after_update(error) do @doc """
error Updates filter indexes when a user's name changes.
end
Updates search indexes to reflect a user's new name.
## Examples
iex> user_name_reindex("old_name", "new_name")
:ok
"""
def user_name_reindex(old_name, new_name) do def user_name_reindex(old_name, new_name) do
data = FilterIndex.user_name_update_by_query(old_name, new_name) data = FilterIndex.user_name_update_by_query(old_name, new_name)
Search.update_by_query(Filter, data.query, data.set_replacements, data.replacements) Search.update_by_query(Filter, data.query, data.set_replacements, data.replacements)
end end
@doc """
Queues a single filter for search index updates.
Returns the filter struct unchanged, for use in a pipeline.
## Examples
iex> reindex_filter(filter)
%Filter{}
"""
def reindex_filter(%Filter{} = filter) do def reindex_filter(%Filter{} = filter) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Filters", "id", [filter.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Filters", "id", [filter.id]])
filter filter
end end
@doc """
Removes a filter from the search index.
## Examples
iex> unindex_filter(filter)
%Filter{}
"""
def unindex_filter(%Filter{} = filter) do def unindex_filter(%Filter{} = filter) do
Search.delete_document(filter.id, Filter) Search.delete_document(filter.id, Filter)
filter filter
end end
@doc """
Returns a list of associations to preload when indexing filters.
## Examples
iex> indexing_preloads()
[:user]
"""
def indexing_preloads do def indexing_preloads do
[:user] [:user]
end end
@doc """
Performs a search reindex operation on filters matching the given criteria.
## Parameters
- column: The database column to filter on (e.g., :id)
- condition: A list of values to match against the column
## Examples
iex> perform_reindex(:id, [1, 2, 3])
{:ok, [%Filter{}, ...]}
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Filter Filter
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())

View file

@ -121,6 +121,15 @@ defmodule Philomena.Galleries do
Gallery.changeset(gallery, %{}) Gallery.changeset(gallery, %{})
end end
@doc """
Updates gallery search indices when a user's name changes.
## Examples
iex> user_name_reindex("old_username", "new_username")
:ok
"""
def user_name_reindex(old_name, new_name) do def user_name_reindex(old_name, new_name) do
data = GalleryIndex.user_name_update_by_query(old_name, new_name) data = GalleryIndex.user_name_update_by_query(old_name, new_name)
@ -137,22 +146,65 @@ defmodule Philomena.Galleries do
error error
end end
@doc """
Queues a gallery for reindexing.
Adds the gallery to the indexing queue to update its search index.
## Examples
iex> reindex_gallery(gallery)
%Gallery{}
"""
def reindex_gallery(%Gallery{} = gallery) do def reindex_gallery(%Gallery{} = gallery) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", [gallery.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", [gallery.id]])
gallery gallery
end end
@doc """
Removes a gallery from the search index.
Deletes the gallery's document from the search index.
## Examples
iex> unindex_gallery(gallery)
%Gallery{}
"""
def unindex_gallery(%Gallery{} = gallery) do def unindex_gallery(%Gallery{} = gallery) do
Search.delete_document(gallery.id, Gallery) Search.delete_document(gallery.id, Gallery)
gallery gallery
end end
@doc """
Returns a list of associations to preload when indexing galleries.
## Examples
iex> indexing_preloads()
[:subscribers, :creator, :interactions]
"""
def indexing_preloads do def indexing_preloads do
[:subscribers, :creator, :interactions] [:subscribers, :creator, :interactions]
end end
@doc """
Reindexes galleries based on a column condition.
Updates the search index for all galleries matching the given column condition.
Used for batch reindexing of galleries.
## Examples
iex> perform_reindex(:id, [1, 2, 3])
{:ok, [%Gallery{}, ...]}
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Gallery Gallery
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())
@ -160,6 +212,24 @@ defmodule Philomena.Galleries do
|> Search.reindex(Gallery) |> Search.reindex(Gallery)
end end
@doc """
Adds the specified image to the gallery, updates image count, triggers
notifications, and performs necessary reindexing.
The image is added at the last position.
## Examples
iex> add_image_to_gallery(gallery, image)
{:ok,
%{
gallery: %Gallery{},
interaction: %Interaction{},
image_count: 1,
notification: %Notification{}
}}
"""
def add_image_to_gallery(gallery, image) do def add_image_to_gallery(gallery, image) do
Multi.new() Multi.new()
|> Multi.run(:gallery, fn repo, %{} -> |> Multi.run(:gallery, fn repo, %{} ->
@ -202,6 +272,21 @@ defmodule Philomena.Galleries do
end end
end end
@doc """
Removes the specified image from the gallery, updates image count,
and performs necessary reindexing.
## Examples
iex> remove_image_from_gallery(gallery, image)
{:ok,
%{
gallery: %Gallery{},
interaction: 1,
image_count: 0
}}
"""
def remove_image_from_gallery(gallery, image) do def remove_image_from_gallery(gallery, image) do
Multi.new() Multi.new()
|> Multi.run(:gallery, fn repo, %{} -> |> Multi.run(:gallery, fn repo, %{} ->
@ -254,10 +339,35 @@ defmodule Philomena.Galleries do
|> Repo.aggregate(:max, :position) |> Repo.aggregate(:max, :position)
end end
@doc """
Queues a gallery reorder operation.
Returns the gallery struct unchanged, for use in a pipeline.
## Examples
iex> reorder_gallery(gallery, [1, 2, 3])
%Gallery{}
"""
def reorder_gallery(gallery, image_ids) do def reorder_gallery(gallery, image_ids) do
Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids]) Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids])
gallery
end end
@doc """
Performs the actual reordering of images in a gallery.
Reorders the gallery's images according to the provided image IDs list, updating
positions while maintaining relative order for unspecified images. Handles position
updates efficiently and reindexes only the affected images.
## Examples
iex> perform_reorder(gallery_id, [3, 1, 2])
:ok
"""
def perform_reorder(gallery_id, image_ids) do def perform_reorder(gallery_id, image_ids) do
gallery = get_gallery!(gallery_id) gallery = get_gallery!(gallery_id)
@ -320,6 +430,8 @@ defmodule Philomena.Galleries do
# Now update all the associated images # Now update all the associated images
Images.reindex_images(Map.keys(requested)) Images.reindex_images(Map.keys(requested))
:ok
end end
defp position_order(%{order_position_asc: true}), do: [asc: :position] defp position_order(%{order_position_asc: true}), do: [asc: :position]

View file

@ -156,6 +156,16 @@ defmodule Philomena.Images do
Logger.error("Aborting upload of #{image.id} after #{retry_count} retries") Logger.error("Aborting upload of #{image.id} after #{retry_count} retries")
end end
@doc """
Approves an image for public viewing.
This will make the image visible to users and update necessary statistics.
## Examples
iex> approve_image(image)
{:ok, %Image{}}
"""
def approve_image(image) do def approve_image(image) do
image image
|> Repo.preload(:user) |> Repo.preload(:user)
@ -197,6 +207,18 @@ defmodule Philomena.Images do
defp maybe_suggest_user_verification(_user), do: false defp maybe_suggest_user_verification(_user), do: false
@doc """
Counts the number of images pending approval that a user can moderate.
## Examples
iex> count_pending_approvals(admin)
42
iex> count_pending_approvals(user)
nil
"""
def count_pending_approvals(user) do def count_pending_approvals(user) do
if Canada.Can.can?(user, :approve, %Image{}) do if Canada.Can.can?(user, :approve, %Image{}) do
Image Image
@ -208,12 +230,36 @@ defmodule Philomena.Images do
end end
end end
@doc """
Marks the given image as the current featured image.
## Examples
iex> feature_image(user, image)
{:ok, %ImageFeature{}}
"""
def feature_image(featurer, %Image{} = image) do def feature_image(featurer, %Image{} = image) do
%ImageFeature{user_id: featurer.id, image_id: image.id} %ImageFeature{user_id: featurer.id, image_id: image.id}
|> ImageFeature.changeset(%{}) |> ImageFeature.changeset(%{})
|> Repo.insert() |> Repo.insert()
end end
@doc """
Destroys the contents of an image (hard deletion) by marking it as hidden
and deleting up associated files.
This will:
1. Mark the image as removed in the database
2. Purge associated files
3. Remove thumbnails
## Examples
iex> destroy_image(image)
{:ok, %Image{}}
"""
def destroy_image(%Image{} = image) do def destroy_image(%Image{} = image) do
image image
|> Image.remove_image_changeset() |> Image.remove_image_changeset()
@ -230,36 +276,91 @@ defmodule Philomena.Images do
end end
end end
@doc """
Locks or unlocks comments on an image.
## Examples
iex> lock_comments(image, true)
{:ok, %Image{}}
"""
def lock_comments(%Image{} = image, locked) do def lock_comments(%Image{} = image, locked) do
image image
|> Image.lock_comments_changeset(locked) |> Image.lock_comments_changeset(locked)
|> Repo.update() |> Repo.update()
end end
@doc """
Locks or unlocks the description of an image.
## Examples
iex> lock_description(image, true)
{:ok, %Image{}}
"""
def lock_description(%Image{} = image, locked) do def lock_description(%Image{} = image, locked) do
image image
|> Image.lock_description_changeset(locked) |> Image.lock_description_changeset(locked)
|> Repo.update() |> Repo.update()
end end
@doc """
Locks or unlocks the tags on an image.
## Examples
iex> lock_tags(image, true)
{:ok, %Image{}}
"""
def lock_tags(%Image{} = image, locked) do def lock_tags(%Image{} = image, locked) do
image image
|> Image.lock_tags_changeset(locked) |> Image.lock_tags_changeset(locked)
|> Repo.update() |> Repo.update()
end end
@doc """
Removes the original SHA-512 hash from an image, allowing users to upload
the same file again.
## Examples
iex> remove_hash(image)
{:ok, %Image{}}
"""
def remove_hash(%Image{} = image) do def remove_hash(%Image{} = image) do
image image
|> Image.remove_hash_changeset() |> Image.remove_hash_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Updates the scratchpad notes on an image.
## Examples
iex> update_scratchpad(image, %{"scratchpad" => "New notes"})
{:ok, %Image{}}
"""
def update_scratchpad(%Image{} = image, attrs) do def update_scratchpad(%Image{} = image, attrs) do
image image
|> Image.scratchpad_changeset(attrs) |> Image.scratchpad_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Removes all source change history for an image.
## Examples
iex> remove_source_history(image)
{:ok, %Image{}}
"""
def remove_source_history(%Image{} = image) do def remove_source_history(%Image{} = image) do
image image
|> Repo.preload(:source_changes) |> Repo.preload(:source_changes)
@ -267,17 +368,49 @@ defmodule Philomena.Images do
|> Repo.update() |> Repo.update()
end end
@doc """
Repairs an image by regenerating its thumbnails.
Returns the image struct unchanged, for use in a pipeline.
This will:
1. Mark the image as needing thumbnail regeneration
2. Queue the thumbnail generation job
## Examples
iex> repair_image(image)
%Image{}
"""
def repair_image(%Image{} = image) do def repair_image(%Image{} = image) do
Image Image
|> where(id: ^image.id) |> where(id: ^image.id)
|> Repo.update_all(set: [thumbnails_generated: false, processed: false]) |> Repo.update_all(set: [thumbnails_generated: false, processed: false])
Exq.enqueue(Exq, queue(image.image_mime_type), ThumbnailWorker, [image.id]) Exq.enqueue(Exq, queue(image.image_mime_type), ThumbnailWorker, [image.id])
image
end end
defp queue("video/webm"), do: "videos" defp queue("video/webm"), do: "videos"
defp queue(_mime_type), do: "images" defp queue(_mime_type), do: "images"
@doc """
Updates the file content of an image.
This will:
1. Update the image metadata
2. Save the new file
3. Generate new thumbnails
4. Purge old files
5. Reindex the image
## Examples
iex> update_file(image, %{"image" => upload})
{:ok, %Image{}}
"""
def update_file(%Image{} = image, attrs) do def update_file(%Image{} = image, attrs) do
image image
|> Image.changeset(attrs) |> Image.changeset(attrs)
@ -316,12 +449,48 @@ defmodule Philomena.Images do
|> Repo.update() |> Repo.update()
end end
@doc """
Updates an image's description.
## Examples
iex> update_description(image, %{"description" => "New description"})
{:ok, %Image{}}
"""
def update_description(%Image{} = image, attrs) do def update_description(%Image{} = image, attrs) do
image image
|> Image.description_changeset(attrs) |> Image.description_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates an image's sources with attribution tracking.
Handles both added and removed sources. Automatically determines the user's
intended source changes based on the provided previous image state.
This will update the image's sources, create source change records
for tracking, and reindex the image.
## Examples
iex> update_sources(
...> image,
...> %{attribution: attrs},
...> %{
...> "old_sources" => %{},
...> "sources" => %{"0" => "http://example.com"}
...> }
...> )
{:ok,
%{
image: image,
added_source_changes: 1,
removed_source_changes: 0
}}
"""
def update_sources(%Image{} = image, attribution, attrs) do def update_sources(%Image{} = image, attribution, attrs) do
old_sources = attrs["old_sources"] old_sources = attrs["old_sources"]
new_sources = attrs["sources"] new_sources = attrs["sources"]
@ -383,6 +552,17 @@ defmodule Philomena.Images do
} }
end end
@doc """
Updates the locked tags on an image.
Locked tags can only be added or removed by privileged users.
## Examples
iex> update_locked_tags(image, %{tag_input: "safe, validated"})
{:ok, %Image{}}
"""
def update_locked_tags(%Image{} = image, attrs) do def update_locked_tags(%Image{} = image, attrs) do
new_tags = Tags.get_or_create_tags(attrs["tag_input"]) new_tags = Tags.get_or_create_tags(attrs["tag_input"])
@ -392,6 +572,33 @@ defmodule Philomena.Images do
|> Repo.update() |> Repo.update()
end end
@doc """
Updates an image's tags with attribution tracking.
Handles both added and removed tags. Automatically determines the user's
intended tag changes based on the provided previous image state.
This will update the image's tags, create tag change records
for tracking, and reindex the image.
## Examples
iex> update_tags(
...> image,
...> %{attribution: attrs},
...> %{
...> old_tag_input: "safe",
...> tag_input: "safe, cute"
...> }
...> )
{:ok,
%{
image: image,
added_tag_changes: 1,
removed_tag_changes: 0
}}
"""
def update_tags(%Image{} = image, attribution, attrs) do def update_tags(%Image{} = image, attribution, attrs) do
old_tags = Tags.get_or_create_tags(attrs["old_tag_input"]) old_tags = Tags.get_or_create_tags(attrs["old_tag_input"])
new_tags = Tags.get_or_create_tags(attrs["tag_input"]) new_tags = Tags.get_or_create_tags(attrs["tag_input"])
@ -486,14 +693,27 @@ defmodule Philomena.Images do
end end
end end
@doc """
Updates the tag change tracking after committing updates to an image.
This updates the rate limit counters for total tag change count and rating change count
based on the changes made to the image.
## Examples
iex> update_tag_change_limits_after_commit(image, %{user: user, ip: "127.0.0.1"})
:ok
"""
def update_tag_change_limits_after_commit(image, attribution) do def update_tag_change_limits_after_commit(image, attribution) do
rating_changed_count = if(image.ratings_changed, do: 1, else: 0) rating_changed_count = if(image.ratings_changed, do: 1, else: 0)
tag_changed_count = length(image.added_tags) + length(image.removed_tags) tag_changed_count = length(image.added_tags) + length(image.removed_tags)
user = attribution[:user] user = attribution[:user]
ip = attribution[:ip] ip = attribution[:ip]
Limits.update_tag_count_after_update(user, ip, tag_changed_count) :ok = Limits.update_tag_count_after_update(user, ip, tag_changed_count)
Limits.update_rating_count_after_update(user, ip, rating_changed_count) :ok = Limits.update_rating_count_after_update(user, ip, rating_changed_count)
:ok
end end
defp tag_change_attributes(attribution, image, tag, added, user) do defp tag_change_attributes(attribution, image, tag, added, user) do
@ -518,18 +738,48 @@ defmodule Philomena.Images do
} }
end end
@doc """
Changes the uploader of an image.
## Examples
iex> update_uploader(image, %{"username" => "Admin"})
{:ok, %Image{}}
"""
def update_uploader(%Image{} = image, attrs) do def update_uploader(%Image{} = image, attrs) do
image image
|> Image.uploader_changeset(attrs) |> Image.uploader_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates the anonymous status of an image.
## Examples
iex> update_anonymous(image, %{"anonymous" => "true"})
{:ok, %Image{}}
"""
def update_anonymous(%Image{} = image, attrs) do def update_anonymous(%Image{} = image, attrs) do
image image
|> Image.anonymous_changeset(attrs) |> Image.anonymous_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates the hide reason for an image.
## Examples
iex> update_hide_reason(image, %{hide_reason: "Duplicate of #1234"})
{:ok, %Image{}}
iex> update_hide_reason(image, %{hide_reason: ""})
{:ok, %Image{}}
"""
def update_hide_reason(%Image{} = image, attrs) do def update_hide_reason(%Image{} = image, attrs) do
image image
|> Image.hide_reason_changeset(attrs) |> Image.hide_reason_changeset(attrs)
@ -545,6 +795,28 @@ defmodule Philomena.Images do
end end
end end
@doc """
Hides an image from public view.
This will:
1. Mark the image as hidden
2. Close all reports and duplicate reports
3. Delete all gallery interactions containing the image
4. Decrement all tag counts with the image
5. Hide the image's thumbnails and purge them from the CDN
6. Reindex the image and all of its comments
## Examples
iex> hide_image(image, moderator, %{reason: "Rule violation"})
{:ok,
%{
image: image,
tags: tags,
reports: {count, reports}
}}
"""
def hide_image(%Image{} = image, user, attrs) do def hide_image(%Image{} = image, user, attrs) do
duplicate_reports = duplicate_reports =
DuplicateReport DuplicateReport
@ -560,6 +832,33 @@ defmodule Philomena.Images do
|> process_after_hide() |> process_after_hide()
end end
@doc """
Merges one image into another, combining their metadata and content.
This will:
1. Hide the source image
2. Update first_seen_at timestamp
3. Copy tags to the target image
4. Migrate sources, comments, subscriptions and interactions
5. Send merge notifications
6. Reindex both images and all of the comments
## Parameters
- multi: Optional `m:Ecto.Multi` for transaction handling
- image: The source image to merge from
- duplicate_of_image: The target image to merge into
- user: The user performing the merge
## Examples
iex> merge_image(nil, source_image, target_image, moderator)
{:ok,
%{
image: image,
tags: tags
}}
"""
def merge_image(multi \\ nil, %Image{} = image, duplicate_of_image, user) do def merge_image(multi \\ nil, %Image{} = image, duplicate_of_image, user) do
multi = multi || Multi.new() multi = multi || Multi.new()
@ -675,6 +974,26 @@ defmodule Philomena.Images do
{:ok, image} {:ok, image}
end end
@doc """
Unhides an image, making it visible to users again.
This will:
1. Remove the hidden status from the image
2. Increment tag counts
3. Unhide thumbnails
4. Reindex the image and related content
Returns {:ok, image} if successful, or returns the image unchanged if it's not hidden.
## Examples
iex> unhide_image(hidden_image)
{:ok, %Image{hidden_from_users: false}}
iex> unhide_image(visible_image)
{:ok, %Image{}}
"""
def unhide_image(%Image{hidden_from_users: true} = image) do def unhide_image(%Image{hidden_from_users: true} = image) do
key = image.hidden_image_key key = image.hidden_image_key
@ -711,6 +1030,24 @@ defmodule Philomena.Images do
def unhide_image(image), do: {:ok, image} def unhide_image(image), do: {:ok, image}
@doc """
Performs a batch update on multiple images, adding and removing tags.
This function efficiently updates tags for multiple images at once,
handling tag changes, tag counts, and reindexing in a single transaction.
## Parameters
- image_ids: List of image IDs to update
- added_tags: List of tags to add to all images
- removed_tags: List of tags to remove from all images
- tag_change_attributes: Attributes tag changes are created with
## Examples
iex> batch_update([1, 2], [tag1], [tag2], %{user_id: user.id})
{:ok, ...}
"""
def batch_update(image_ids, added_tags, removed_tags, tag_change_attributes) do def batch_update(image_ids, added_tags, removed_tags, tag_change_attributes) do
image_ids = image_ids =
Image Image
@ -828,24 +1165,65 @@ defmodule Philomena.Images do
Image.changeset(image, %{}) Image.changeset(image, %{})
end end
@doc """
Updates image search indices when a user's name changes.
## Examples
iex> user_name_reindex("old_username", "new_username")
:ok
"""
def user_name_reindex(old_name, new_name) do def user_name_reindex(old_name, new_name) do
data = ImageIndex.user_name_update_by_query(old_name, new_name) data = ImageIndex.user_name_update_by_query(old_name, new_name)
Search.update_by_query(Image, data.query, data.set_replacements, data.replacements) Search.update_by_query(Image, data.query, data.set_replacements, data.replacements)
end end
@doc """
Queues a single image for search index updates.
Returns the image struct unchanged, for use in a pipeline.
## Examples
iex> reindex_image(image)
%Image{}
"""
def reindex_image(%Image{} = image) do def reindex_image(%Image{} = image) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", [image.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", [image.id]])
image image
end end
@doc """
Queues all listed image IDs for search index updates.
Returns the list unchanged, for use in a pipeline.
## Examples
iex> reindex_images([1, 2, 3])
[1, 2, 3]
"""
def reindex_images(image_ids) do def reindex_images(image_ids) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", image_ids]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", image_ids])
image_ids image_ids
end end
@doc """
Returns the preload configuration for image indexing.
Specifies which associations should be preloaded when indexing images,
optimizing the queries for better performance.
## Examples
iex> indexing_preloads()
[sources: query, user: query, ...]
"""
def indexing_preloads do def indexing_preloads do
user_query = select(User, [u], map(u, [:id, :name])) user_query = select(User, [u], map(u, [:id, :name]))
sources_query = select(Source, [s], map(s, [:image_id, :source])) sources_query = select(Source, [s], map(s, [:image_id, :source]))
@ -869,6 +1247,19 @@ defmodule Philomena.Images do
] ]
end end
@doc """
Performs a search reindex operation on images matching the given criteria.
## Parameters
- column: The database column to filter on (e.g., :id)
- condition: A list of values to match against the column
## Examples
iex> perform_reindex(:id, [1, 2, 3])
:ok
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Image Image
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())
@ -876,6 +1267,17 @@ defmodule Philomena.Images do
|> Search.reindex(Image) |> Search.reindex(Image)
end end
@doc """
Purges image files from the CDN.
Enqueues a job to purge both visible and hidden thumbnail paths for the given image.
## Examples
iex> purge_files(image, "hidden_key")
:ok
"""
def purge_files(image, hidden_key) do def purge_files(image, hidden_key) do
files = files =
if is_nil(hidden_key) do if is_nil(hidden_key) do
@ -888,6 +1290,17 @@ defmodule Philomena.Images do
Exq.enqueue(Exq, "indexing", ImagePurgeWorker, [files]) Exq.enqueue(Exq, "indexing", ImagePurgeWorker, [files])
end end
@doc """
Executes the actual purge operation for image files.
Calls the system purge-cache command to remove the specified files from the CDN cache.
## Examples
iex> perform_purge(["file1.jpg", "file2.jpg"])
:ok
"""
def perform_purge(files) do def perform_purge(files) do
{_out, 0} = System.cmd("purge-cache", [Jason.encode!(%{files: files})]) {_out, 0} = System.cmd("purge-cache", [Jason.encode!(%{files: files})])
@ -896,6 +1309,29 @@ defmodule Philomena.Images do
alias Philomena.Images.Subscription alias Philomena.Images.Subscription
@doc """
Migrates subscriptions and notifications from one image to another.
This function is used during image merging to transfer all subscriptions
and notifications from the source image to the target image. It handles:
1. User subscriptions
2. Comment notifications
3. Merge notifications
Returns `{:ok, {comment_notification_count, merge_notification_count}}`.
## Parameters
- source: The source image to migrate from
- target: The target image to migrate to
## Examples
iex> migrate_subscriptions(source_image, target_image)
{:ok, {5, 2}}
"""
def migrate_subscriptions(source, target) do def migrate_subscriptions(source, target) do
subscriptions = subscriptions =
Subscription Subscription
@ -941,6 +1377,29 @@ defmodule Philomena.Images do
{:ok, {comment_notification_count, merge_notification_count}} {:ok, {comment_notification_count, merge_notification_count}}
end end
@doc """
Migrates source URLs from one image to another.
This function is used during image merging to combine source URLs from both images.
It will:
1. Combine sources from both images
2. Remove duplicates
3. Take up to 15 sources (the system limit)
4. Update the target image with the combined sources
Returns the result of updating the target image with the combined sources.
## Parameters
- source: The source image containing sources to migrate
- target: The target image to receive the combined sources
## Examples
iex> migrate_sources(source_image, target_image)
{:ok, %Image{}}
"""
def migrate_sources(source, target) do def migrate_sources(source, target) do
sources = sources =
(source.sources ++ target.sources) (source.sources ++ target.sources)

View file

@ -8,6 +8,19 @@ defmodule Philomena.Interactions do
alias Philomena.Repo alias Philomena.Repo
alias Ecto.Multi alias Ecto.Multi
@doc """
Gets all interactions for a list of images for a given user.
Returns an empty list if no user is provided. Otherwise returns a list of maps containing:
- image_id: The ID of the image
- user_id: The ID of the user
- interaction_type: One of "hidden", "faved", or "voted"
- value: For votes, either "up" or "down". Empty string for other interaction types.
## Parameters
* images - List of images or image IDs to get interactions for
* user - The user to get interactions for, or nil
"""
def user_interactions(_images, nil), def user_interactions(_images, nil),
do: [] do: []
@ -71,6 +84,18 @@ defmodule Philomena.Interactions do
|> Repo.all() |> Repo.all()
end end
@doc """
Migrates all interactions from one image to another.
Copies all hides, faves, and votes from the source image to the target image.
Updates the target image's counters to reflect the new interactions.
All operations are performed in a single transaction.
## Parameters
* source - The source Image struct to copy interactions from
* target - The target Image struct to copy interactions to
"""
def migrate_interactions(source, target) do def migrate_interactions(source, target) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters]) source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters])

View file

@ -111,6 +111,23 @@ defmodule Philomena.Posts do
Notifications.create_forum_post_notification(post.user, topic, post) Notifications.create_forum_post_notification(post.user, topic, post)
end end
@doc """
Creates a system report for non-approved posts containing external images.
Returns false for already approved posts.
## Returns
- `false`: If the post is already approved
- `{:ok, %Report{}}`: If a system report was created
## Examples
iex> report_non_approved(approved_post)
false
iex> report_non_approved(unapproved_post)
{:ok, %Report{}}
"""
def report_non_approved(%Post{approved: true}), do: false def report_non_approved(%Post{approved: true}), do: false
def report_non_approved(post) do def report_non_approved(post) do
@ -176,6 +193,20 @@ defmodule Philomena.Posts do
Repo.delete(post) Repo.delete(post)
end end
@doc """
Hides a post and handles associated reports.
## Parameters
- post: The post to hide
- attrs: Attributes for the hide operation
- user: The user performing the hide action
## Examples
iex> hide_post(post, %{staff_note: "Rule violation"}, user)
{:ok, %Post{}}
"""
def hide_post(%Post{} = post, attrs, user) do def hide_post(%Post{} = post, attrs, user) do
report_query = Reports.close_report_query({"Post", post.id}, user) report_query = Reports.close_report_query({"Post", post.id}, user)
@ -209,6 +240,15 @@ defmodule Philomena.Posts do
end end
end end
@doc """
Unhides a previously hidden post.
## Examples
iex> unhide_post(post)
{:ok, %Post{}}
"""
def unhide_post(%Post{} = post) do def unhide_post(%Post{} = post) do
post post
|> Post.unhide_changeset() |> Post.unhide_changeset()
@ -216,6 +256,15 @@ defmodule Philomena.Posts do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Marks a post as destroyed and removes its text (hard deletion).
## Examples
iex> destroy_post(post)
{:ok, %Post{}}
"""
def destroy_post(%Post{} = post) do def destroy_post(%Post{} = post) do
post post
|> Post.destroy_changeset() |> Post.destroy_changeset()
@ -223,6 +272,20 @@ defmodule Philomena.Posts do
|> reindex_after_update() |> reindex_after_update()
end end
@doc """
Approves a post, closes associated reports, and increments the user forum
posts count.
## Parameters
- post: The post to approve
- user: The user performing the approval
## Examples
iex> approve_comment(post, user)
{:ok, %Post{}}
"""
def approve_post(%Post{} = post, user) do def approve_post(%Post{} = post, user) do
report_query = Reports.close_report_query({"Post", post.id}, user) report_query = Reports.close_report_query({"Post", post.id}, user)
post = Post.approve_changeset(post) post = Post.approve_changeset(post)
@ -257,6 +320,15 @@ defmodule Philomena.Posts do
Post.changeset(post, %{}) Post.changeset(post, %{})
end end
@doc """
Updates post search indices when a user's name changes.
## Examples
iex> user_name_reindex("old_username", "new_username")
:ok
"""
def user_name_reindex(old_name, new_name) do def user_name_reindex(old_name, new_name) do
data = PostIndex.user_name_update_by_query(old_name, new_name) data = PostIndex.user_name_update_by_query(old_name, new_name)
@ -273,12 +345,31 @@ defmodule Philomena.Posts do
result result
end end
@doc """
Queues a single post for search index updates.
Returns the post struct unchanged, for use in a pipeline.
## Examples
iex> reindex_comment(post)
%Post{}
"""
def reindex_post(%Post{} = post) do def reindex_post(%Post{} = post) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "id", [post.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "id", [post.id]])
post post
end end
@doc """
Provides preload queries for post indexing operations.
## Examples
iex> indexing_preloads()
[user: user_query, topic: topic_query]
"""
def indexing_preloads do def indexing_preloads do
user_query = select(User, [u], map(u, [:id, :name])) user_query = select(User, [u], map(u, [:id, :name]))
@ -293,6 +384,22 @@ defmodule Philomena.Posts do
] ]
end end
@doc """
Performs a search reindex operation on posts matching the given criteria.
## Parameters
- column: The database column to filter on (e.g., :id, :topic_id)
- condition: A list of values to match against the column
## Examples
iex> perform_reindex(:id, [1, 2, 3])
:ok
iex> perform_reindex(:topic_id, [123])
:ok
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Post Post
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())

View file

@ -1,29 +1,32 @@
defmodule Philomena.Slug do defmodule Philomena.Slug do
# Generates a URL-safe slug from a string by removing nonessential @moduledoc """
# information from it. URL-safe string shortening.
# """
# The process for this is as follows:
# @doc """
# 1. Remove non-ASCII or non-printable characters. Generates a URL-safe slug from a string by removing nonessential
# information from it.
# 2. Replace any runs of non-alphanumeric characters that were allowed
# through previously with hyphens. The process for this is as follows:
#
# 3. Remove any starting or ending hyphens. 1. Remove non-ASCII or non-printable characters.
# 2. Replace any runs of non-alphanumeric characters that were allowed
# 4. Convert all characters to their lowercase equivalents. through previously with hyphens.
# 3. Remove any starting or ending hyphens.
# This method makes no guarantee of creating unique slugs for unique inputs. 4. Convert all characters to their lowercase equivalents.
# In addition, for certain inputs, it will return empty strings.
# This method makes no guarantee of creating unique slugs for unique inputs.
# Example In addition, for certain inputs, it will return empty strings.
#
# destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)") ## Example
# #=> "time-wasting-thread-3-0-sfw-no-explicit-grimdark"
# iex> destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)")
# destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?") "time-wasting-thread-3-0-sfw-no-explicit-grimdark"
# #=> ""
# iex> destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?")
""
"""
@spec destructive_slug(String.t()) :: String.t() @spec destructive_slug(String.t()) :: String.t()
def destructive_slug(input) when is_binary(input) do def destructive_slug(input) when is_binary(input) do
input input
@ -39,6 +42,11 @@ defmodule Philomena.Slug do
def destructive_slug(_input), do: "" def destructive_slug(_input), do: ""
@doc """
Generates a reversible slug from a string by replacing certain characters
with escaped (not URL-encoded) equivalents.
"""
@spec slug(String.t()) :: String.t()
def slug(string) when is_binary(string) do def slug(string) when is_binary(string) do
string string
|> String.replace("-", "-dash-") |> String.replace("-", "-dash-")

View file

@ -24,6 +24,19 @@ defmodule Philomena.Tags do
alias Philomena.DnpEntries.DnpEntry alias Philomena.DnpEntries.DnpEntry
alias Philomena.Channels.Channel alias Philomena.Channels.Channel
@doc """
Gets existing tags or creates new ones from a tag list string.
Takes a string of comma-separated tag names, parses it into individual tags,
and either retrieves existing tags or creates new ones for tags that don't exist.
Also handles tag aliases by returning the aliased tag instead of the alias.
## Examples
iex> get_or_create_tags("safe, cute, pony")
[%Tag{name: "safe"}, %Tag{name: "cute"}, %Tag{name: "pony"}]
"""
@spec get_or_create_tags(String.t()) :: list() @spec get_or_create_tags(String.t()) :: list()
def get_or_create_tags(tag_list) do def get_or_create_tags(tag_list) do
tag_names = Tag.parse_tag_list(tag_list) tag_names = Tag.parse_tag_list(tag_list)
@ -174,6 +187,18 @@ defmodule Philomena.Tags do
end end
end end
@doc """
Updates a tag's associated image.
Takes a tag and image upload attributes, analyzes the upload,
persists it, and removes the old tag image if successful.
## Examples
iex> update_tag_image(tag, %{"image" => upload})
{:ok, %Tag{}}
"""
def update_tag_image(%Tag{} = tag, attrs) do def update_tag_image(%Tag{} = tag, attrs) do
tag tag
|> Uploader.analyze_upload(attrs) |> Uploader.analyze_upload(attrs)
@ -190,6 +215,17 @@ defmodule Philomena.Tags do
end end
end end
@doc """
Removes a tag's associated image.
Removes the image from the tag and deletes the persisted file.
## Examples
iex> remove_tag_image(tag)
{:ok, %Tag{}}
"""
def remove_tag_image(%Tag{} = tag) do def remove_tag_image(%Tag{} = tag) do
tag tag
|> Tag.remove_image_changeset() |> Tag.remove_image_changeset()
@ -223,6 +259,18 @@ defmodule Philomena.Tags do
{:ok, tag} {:ok, tag}
end end
@doc """
Performs the actual deletion of a tag.
Removes the tag from the database, deletes its search index,
and reindexes all images that were tagged with it.
## Examples
iex> perform_delete(123)
:ok
"""
def perform_delete(tag_id) do def perform_delete(tag_id) do
tag = get_tag!(tag_id) tag = get_tag!(tag_id)
@ -243,6 +291,19 @@ defmodule Philomena.Tags do
|> Search.reindex(Image) |> Search.reindex(Image)
end end
@doc """
Creates an alias from one tag to another.
Takes a source tag and target tag name, creating an alias relationship
where the source tag becomes an alias of the target tag. Once the alias
is created, a job is queued to finish processing the alias.
## Examples
iex> alias_tag(source_tag, %{"target_tag" => "destination"})
{:ok, %Tag{}}
"""
def alias_tag(%Tag{} = tag, attrs) do def alias_tag(%Tag{} = tag, attrs) do
target_tag = Repo.get_by(Tag, name: String.downcase(attrs["target_tag"])) target_tag = Repo.get_by(Tag, name: String.downcase(attrs["target_tag"]))
@ -261,6 +322,19 @@ defmodule Philomena.Tags do
end end
end end
@doc """
Performs the actual tag aliasing operation.
Transfers all associations from the source tag to the target tag,
including image taggings, filters, user watches, and other relationships.
Updates counters and reindexes affected records.
## Examples
iex> perform_alias(123, 456)
:ok
"""
def perform_alias(tag_id, target_tag_id) do def perform_alias(tag_id, target_tag_id) do
tag = get_tag!(tag_id) tag = get_tag!(tag_id)
target_tag = get_tag!(target_tag_id) target_tag = get_tag!(target_tag_id)
@ -315,14 +389,36 @@ defmodule Philomena.Tags do
# Finally, reindex # Finally, reindex
reindex_tag_images(target_tag) reindex_tag_images(target_tag)
reindex_tags([tag, target_tag]) reindex_tags([tag, target_tag])
:ok
end end
@doc """
Enqueues reindexing of all images associated with a tag.
## Examples
iex> reindex_tag_images(tag)
{:ok, %Tag{}}
"""
def reindex_tag_images(%Tag{} = tag) do def reindex_tag_images(%Tag{} = tag) do
Exq.enqueue(Exq, "indexing", TagReindexWorker, [tag.id]) Exq.enqueue(Exq, "indexing", TagReindexWorker, [tag.id])
{:ok, tag} {:ok, tag}
end end
@doc """
Performs reindexing of all images associated with a tag.
Updates the tag's image count to reflect the current number of non-hidden images,
then reindexes all associated images and filters that reference this tag.
## Examples
iex> perform_reindex_images(123)
"""
def perform_reindex_images(tag_id) do def perform_reindex_images(tag_id) do
tag = get_tag!(tag_id) tag = get_tag!(tag_id)
@ -351,12 +447,32 @@ defmodule Philomena.Tags do
|> Search.reindex(Filter) |> Search.reindex(Filter)
end end
@doc """
Enqueues removal of a tag alias.
## Examples
iex> unalias_tag(tag)
{:ok, %Tag{}}
"""
def unalias_tag(%Tag{} = tag) do def unalias_tag(%Tag{} = tag) do
Exq.enqueue(Exq, "indexing", TagUnaliasWorker, [tag.id]) Exq.enqueue(Exq, "indexing", TagUnaliasWorker, [tag.id])
{:ok, tag} {:ok, tag}
end end
@doc """
Performs removal of a tag alias.
Removes the alias relationship between two tags and reindexes
the images of the formerly aliased tag.
## Examples
iex> perform_unalias(123)
{:ok, %Tag{}}
"""
def perform_unalias(tag_id) do def perform_unalias(tag_id) do
tag = get_tag!(tag_id) tag = get_tag!(tag_id)
former_alias = Repo.preload(tag, :aliased_tag).aliased_tag former_alias = Repo.preload(tag, :aliased_tag).aliased_tag
@ -389,6 +505,18 @@ defmodule Philomena.Tags do
|> Repo.update_all([]) |> Repo.update_all([])
end end
@doc """
Copies tags from one image to another.
Creates new taggings on the target image for all tags present on the source image,
updates tag counters, and returns the list of copied tags.
## Examples
iex> copy_tags(source_image, target_image)
[%Tag{}, ...]
"""
def copy_tags(source, target) do def copy_tags(source, target) do
# Ecto bug: # Ecto bug:
# ** (DBConnection.EncodeError) Postgrex expected a binary, got 5. # ** (DBConnection.EncodeError) Postgrex expected a binary, got 5.
@ -437,22 +565,66 @@ defmodule Philomena.Tags do
Tag.changeset(tag, %{}) Tag.changeset(tag, %{})
end end
@doc """
Queues a single tag for search index updates.
Returns the tag struct unchanged, for use in a pipeline.
## Examples
iex> reindex_tag(tag)
%Tag{}
"""
def reindex_tag(%Tag{} = tag) do def reindex_tag(%Tag{} = tag) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", [tag.id]]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", [tag.id]])
tag tag
end end
@doc """
Queues a list of tags for search index updates.
Returns the list of tags unchanged, for use in a pipeline.
## Examples
iex> reindex_tags([%Tag{}, %Tag{}, ...])
[%Tag{}, %Tag{}, ...]
"""
def reindex_tags(tags) do def reindex_tags(tags) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", Enum.map(tags, & &1.id)]) Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", Enum.map(tags, & &1.id)])
tags tags
end end
@doc """
Returns the list of associations to preload for tag indexing.
## Examples
iex> indexing_preloads()
[:aliased_tag, :aliases, :implied_tags, :implied_by_tags]
"""
def indexing_preloads do def indexing_preloads do
[:aliased_tag, :aliases, :implied_tags, :implied_by_tags] [:aliased_tag, :aliases, :implied_tags, :implied_by_tags]
end end
@doc """
Performs reindexing of tags based on a column condition.
Takes a column name and a list of values to match against that column,
then reindexes all matching tags.
## Examples
iex> perform_reindex(:id, [1, 2, 3])
{:ok, []}
iex> perform_reindex(:name, ["safe", "suggestive"])
{:ok, []}
"""
def perform_reindex(column, condition) do def perform_reindex(column, condition) do
Tag Tag
|> preload(^indexing_preloads()) |> preload(^indexing_preloads())

View file

@ -138,26 +138,71 @@ defmodule Philomena.Topics do
Topic.changeset(topic, %{}) Topic.changeset(topic, %{})
end end
@doc """
Makes a topic sticky, appearing at the top of its forum.
## Examples
iex> stick_topic(topic)
{:ok, %Topic{}}
"""
def stick_topic(topic) do def stick_topic(topic) do
Topic.stick_changeset(topic) Topic.stick_changeset(topic)
|> Repo.update() |> Repo.update()
end end
@doc """
Removes sticky status from a topic.
## Examples
iex> unstick_topic(topic)
{:ok, %Topic{}}
"""
def unstick_topic(topic) do def unstick_topic(topic) do
Topic.unstick_changeset(topic) Topic.unstick_changeset(topic)
|> Repo.update() |> Repo.update()
end end
@doc """
Locks a topic to prevent further posting.
## Examples
iex> lock_topic(topic, %{"lock_reason" => "Off topic"}, user)
{:ok, %Topic{}}
"""
def lock_topic(%Topic{} = topic, attrs, user) do def lock_topic(%Topic{} = topic, attrs, user) do
Topic.lock_changeset(topic, attrs, user) Topic.lock_changeset(topic, attrs, user)
|> Repo.update() |> Repo.update()
end end
@doc """
Unlocks a topic to allow posting again.
## Examples
iex> unlock_topic(topic)
{:ok, %Topic{}}
"""
def unlock_topic(%Topic{} = topic) do def unlock_topic(%Topic{} = topic) do
Topic.unlock_changeset(topic) Topic.unlock_changeset(topic)
|> Repo.update() |> Repo.update()
end end
@doc """
Moves a topic to a different forum, updating post counts for both forums.
## Examples
iex> move_topic(topic, 123)
{:ok, %{topic: %Topic{}}}
"""
def move_topic(topic, new_forum_id) do def move_topic(topic, new_forum_id) do
old_forum_id = topic.forum_id old_forum_id = topic.forum_id
topic_changes = Topic.move_changeset(topic, new_forum_id) topic_changes = Topic.move_changeset(topic, new_forum_id)
@ -183,6 +228,15 @@ defmodule Philomena.Topics do
|> Repo.transaction() |> Repo.transaction()
end end
@doc """
Hides a topic and updates related forum data.
## Examples
iex> hide_topic(topic, "Violates rules", moderator)
{:ok, %Topic{}}
"""
def hide_topic(topic, deletion_reason, user) do def hide_topic(topic, deletion_reason, user) do
topic_changes = Topic.hide_changeset(topic, deletion_reason, user) topic_changes = Topic.hide_changeset(topic, deletion_reason, user)
@ -205,11 +259,29 @@ defmodule Philomena.Topics do
end end
end end
@doc """
Unhides a previously hidden topic.
## Examples
iex> unhide_topic(topic)
{:ok, %Topic{}}
"""
def unhide_topic(topic) do def unhide_topic(topic) do
Topic.unhide_changeset(topic) Topic.unhide_changeset(topic)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates a topic's title.
## Examples
iex> update_topic_title(topic, %{"title" => "New Title"})
{:ok, %Topic{}}
"""
def update_topic_title(topic, attrs) do def update_topic_title(topic, attrs) do
topic topic
|> Topic.title_changeset(attrs) |> Topic.title_changeset(attrs)

View file

@ -273,6 +273,12 @@ defmodule Philomena.Users do
@doc """ @doc """
Unconditionally unlocks the given user. Unconditionally unlocks the given user.
## Examples
iex> unlock_user(user)
{:ok, %User{}}
""" """
def unlock_user(user) do def unlock_user(user) do
user user
@ -369,6 +375,20 @@ defmodule Philomena.Users do
load_with_roles(query) load_with_roles(query)
end end
@doc """
Checks if a TOTP token is valid for a given user.
Returns false if no user is provided.
## Examples
iex> user_totp_token_valid?(user, "123456")
true
iex> user_totp_token_valid?(nil, "123456")
false
"""
def user_totp_token_valid?(nil, _token) do def user_totp_token_valid?(nil, _token) do
false false
end end
@ -503,6 +523,15 @@ defmodule Philomena.Users do
end end
end end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user(%User{} = user) do def change_user(%User{} = user) do
User.changeset(user, %{}) User.changeset(user, %{})
end end
@ -544,30 +573,84 @@ defmodule Philomena.Users do
defp clean_roles(nil), do: [] defp clean_roles(nil), do: []
defp clean_roles(roles), do: Enum.filter(roles, &("" != &1)) defp clean_roles(roles), do: Enum.filter(roles, &("" != &1))
@doc """
Updates a user's spoiler type settings.
## Examples
iex> update_spoiler_type(user, %{spoiler_type: "click"})
{:ok, %User{}}
iex> update_spoiler_type(user, %{spoiler_type: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_spoiler_type(%User{} = user, attrs) do def update_spoiler_type(%User{} = user, attrs) do
user user
|> User.spoiler_type_changeset(attrs) |> User.spoiler_type_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates a user's general settings.
## Examples
iex> update_settings(user, %{"theme" => "dark"})
{:ok, %User{}}
iex> update_settings(user, %{"theme" => bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_settings(%User{} = user, attrs) do def update_settings(%User{} = user, attrs) do
user user
|> User.settings_changeset(attrs) |> User.settings_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates a user's profile description and personal title.
## Examples
iex> update_description(user, %{"description" => "Hello world"})
{:ok, %User{}}
iex> update_description(user, %{"personal_title" => "Site Admin"})
{:error, %Ecto.Changeset{}}
"""
def update_description(%User{} = user, attrs) do def update_description(%User{} = user, attrs) do
user user
|> User.description_changeset(attrs) |> User.description_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Updates a user's moderation scratchpad content.
## Examples
iex> update_scratchpad(user, %{"scratchpad" => "My notes"})
{:ok, %User{}}
"""
def update_scratchpad(%User{} = user, attrs) do def update_scratchpad(%User{} = user, attrs) do
user user
|> User.scratchpad_changeset(attrs) |> User.scratchpad_changeset(attrs)
|> Repo.update() |> Repo.update()
end end
@doc """
Adds a tag to a user's watched tags list.
## Examples
iex> watch_tag(user, tag)
{:ok, %User{}}
"""
def watch_tag(%User{} = user, tag) do def watch_tag(%User{} = user, tag) do
watched_tag_ids = Enum.uniq([tag.id | user.watched_tag_ids]) watched_tag_ids = Enum.uniq([tag.id | user.watched_tag_ids])
@ -576,6 +659,15 @@ defmodule Philomena.Users do
|> Repo.update() |> Repo.update()
end end
@doc """
Removes a tag from a user's watched tags list.
## Examples
iex> unwatch_tag(user, tag)
{:ok, %User{}}
"""
def unwatch_tag(%User{} = user, tag) do def unwatch_tag(%User{} = user, tag) do
watched_tag_ids = user.watched_tag_ids -- [tag.id] watched_tag_ids = user.watched_tag_ids -- [tag.id]
@ -584,6 +676,17 @@ defmodule Philomena.Users do
|> Repo.update() |> Repo.update()
end end
@doc """
Updates a user's avatar with the provided file.
Handles file analysis and persistence.
## Examples
iex> update_avatar(user, %{"avatar" => upload})
{:ok, %User{}}
"""
def update_avatar(%User{} = user, attrs) do def update_avatar(%User{} = user, attrs) do
user user
|> Uploader.analyze_upload(attrs) |> Uploader.analyze_upload(attrs)
@ -600,6 +703,15 @@ defmodule Philomena.Users do
end end
end end
@doc """
Removes a user's avatar.
## Examples
iex> remove_avatar(user)
{:ok, %User{}}
"""
def remove_avatar(%User{} = user) do def remove_avatar(%User{} = user) do
user user
|> User.remove_avatar_changeset() |> User.remove_avatar_changeset()
@ -615,6 +727,17 @@ defmodule Philomena.Users do
end end
end end
@doc """
Updates a user's name and records the change in history.
Triggers a background job to update references to the old username.
## Examples
iex> update_name(user, %{"name" => "new_name"})
{:ok, %User{}}
"""
def update_name(user, user_params) do def update_name(user, user_params) do
old_name = user.name old_name = user.name
@ -636,6 +759,17 @@ defmodule Philomena.Users do
end end
end end
@doc """
Updates all search engine references to a user's old name with their new name.
This is called as a background job after a user requests a name change.
## Examples
iex> perform_rename("old_name", "new_name")
:ok
"""
def perform_rename(old_name, new_name) do def perform_rename(old_name, new_name) do
Images.user_name_reindex(old_name, new_name) Images.user_name_reindex(old_name, new_name)
Comments.user_name_reindex(old_name, new_name) Comments.user_name_reindex(old_name, new_name)
@ -645,36 +779,96 @@ defmodule Philomena.Users do
Filters.user_name_reindex(old_name, new_name) Filters.user_name_reindex(old_name, new_name)
end end
@doc """
Reactivates a previously deactivated user account.
## Examples
iex> reactivate_user(user)
{:ok, %User{}}
"""
def reactivate_user(%User{} = user) do def reactivate_user(%User{} = user) do
user user
|> User.reactivate_changeset() |> User.reactivate_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Deactivates a user account.
Takes a moderator who is recorded as performing the deactivation.
## Examples
iex> deactivate_user(moderator, user)
{:ok, %User{}}
"""
def deactivate_user(moderator, %User{} = user) do def deactivate_user(moderator, %User{} = user) do
user user
|> User.deactivate_changeset(moderator) |> User.deactivate_changeset(moderator)
|> Repo.update() |> Repo.update()
end end
@doc """
Generates a new API key for the user.
## Examples
iex> reset_api_key(user)
{:ok, %User{}}
"""
def reset_api_key(%User{} = user) do def reset_api_key(%User{} = user) do
user user
|> User.api_key_changeset() |> User.api_key_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Forces a specific filter on a user's account, which will be applied in
conjunction to the user's current filter.
## Examples
iex> force_filter(user, %{"forced_filter_id" => 123})
{:ok, %User{}}
iex> force_filter(user, %{"forced_filter_id" => bad_value})
{:error, %Ecto.Changeset{}}
"""
def force_filter(%User{} = user, user_params) do def force_filter(%User{} = user, user_params) do
user user
|> User.force_filter_changeset(user_params) |> User.force_filter_changeset(user_params)
|> Repo.update() |> Repo.update()
end end
@doc """
Removes a forced filter from a user's account.
## Examples
iex> unforce_filter(user)
{:ok, %User{}}
"""
def unforce_filter(%User{} = user) do def unforce_filter(%User{} = user) do
user user
|> User.unforce_filter_changeset() |> User.unforce_filter_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Clears a user's recent filter history.
## Examples
iex> clear_recent_filters(user)
{:ok, %User{}}
"""
def clear_recent_filters(%User{} = user) do def clear_recent_filters(%User{} = user) do
user user
|> User.clear_recent_filters_changeset() |> User.clear_recent_filters_changeset()
@ -688,18 +882,50 @@ defmodule Philomena.Users do
|> setup_roles() |> setup_roles()
end end
@doc """
Marks a user as verified for the purposes of automatically approving uploads,
and posting images in comments/posts/messages without moderator review.
## Examples
iex> verify_user(user)
{:ok, %User{}}
"""
def verify_user(%User{} = user) do def verify_user(%User{} = user) do
user user
|> User.verify_changeset() |> User.verify_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Unverifies a user, removing the automatic approval status.
## Examples
iex> unverify_user(user)
{:ok, %User{}}
"""
def unverify_user(%User{} = user) do def unverify_user(%User{} = user) do
user user
|> User.unverify_changeset() |> User.unverify_changeset()
|> Repo.update() |> Repo.update()
end end
@doc """
Erases all changes associated with a user account, removing all personal
data and anonymizing the account.
This is primarily intended for use with spam accounts or other situations
where all of a user's data should be removed from the system.
## Examples
iex> erase_user(user, moderator)
{:ok, %User{}}
"""
def erase_user(%User{} = user, %User{} = moderator) do def erase_user(%User{} = user, %User{} = moderator) do
# Deactivate to prevent the user from racing these changes # Deactivate to prevent the user from racing these changes
{:ok, user} = deactivate_user(moderator, user) {:ok, user} = deactivate_user(moderator, user)