diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 2d2360fe..f7db9dfc 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -119,6 +119,20 @@ defmodule Philomena.Comments do Repo.delete(comment) 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 report_query = Reports.close_report_query({"Comment", comment.id}, user) comment = Comment.hide_changeset(comment, attrs, user) @@ -139,6 +153,15 @@ defmodule Philomena.Comments do end end + @doc """ + Unhides a previously hidden comment. + + ## Examples + + iex> unhide_comment(comment) + {:ok, %Comment{}} + + """ def unhide_comment(%Comment{} = comment) do comment |> Comment.unhide_changeset() @@ -154,12 +177,35 @@ defmodule Philomena.Comments do 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 comment |> Comment.destroy_changeset() |> Repo.update() 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 report_query = Reports.close_report_query({"Comment", comment.id}, user) comment = Comment.approve_changeset(comment) @@ -181,6 +227,23 @@ defmodule Philomena.Comments do 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) do @@ -191,6 +254,20 @@ defmodule Philomena.Comments do ) 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 {count, nil} = Comment @@ -217,24 +294,62 @@ defmodule Philomena.Comments do Comment.changeset(comment, %{}) 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 data = CommentIndex.user_name_update_by_query(old_name, new_name) Search.update_by_query(Comment, data.query, data.set_replacements, data.replacements) 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "id", [comment.id]]) comment 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "image_id", [image.id]]) image end + @doc """ + Provides preload queries for comment indexing operations. + + ## Examples + + iex> indexing_preloads() + [user: user_query, image: image_query] + + """ def indexing_preloads do user_query = select(User, [u], map(u, [:id, :name])) tag_query = select(Tag, [t], map(t, [:id])) @@ -250,6 +365,22 @@ defmodule Philomena.Comments do ] 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 Comment |> preload(^indexing_preloads()) diff --git a/lib/philomena/dnp_entries.ex b/lib/philomena/dnp_entries.ex index 03946710..96b9bd5a 100644 --- a/lib/philomena/dnp_entries.ex +++ b/lib/philomena/dnp_entries.ex @@ -77,6 +77,18 @@ defmodule Philomena.DnpEntries do |> Repo.update() 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 dnp_entry |> DnpEntry.transition_changeset(user, new_state) @@ -112,6 +124,19 @@ defmodule Philomena.DnpEntries do DnpEntry.changeset(dnp_entry, %{}) 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 if Canada.Can.can?(user, :index, DnpEntry) do DnpEntry diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index a9cad67b..a920e87a 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -16,6 +16,18 @@ defmodule Philomena.DuplicateReports do alias Philomena.Images.Image 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 source = Repo.preload(source, :intensity) @@ -30,6 +42,23 @@ defmodule Philomena.DuplicateReports do 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 aspect_dist = Keyword.get(opts, :aspect_dist, 0.05) limit = Keyword.get(opts, :limit, 10) @@ -150,6 +179,21 @@ defmodule Philomena.DuplicateReports do |> Repo.insert() 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 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) 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 new_report = DuplicateReport @@ -204,18 +260,47 @@ defmodule Philomena.DuplicateReports do |> accept_duplicate_report(new_report, user) 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 duplicate_report |> DuplicateReport.claim_changeset(user) |> Repo.update() 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 duplicate_report |> DuplicateReport.unclaim_changeset() |> Repo.update() 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 duplicate_report |> DuplicateReport.reject_changeset(user) @@ -251,6 +336,19 @@ defmodule Philomena.DuplicateReports do DuplicateReport.changeset(duplicate_report, %{}) 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 if Canada.Can.can?(user, :index, DuplicateReport) do DuplicateReport diff --git a/lib/philomena/filters.ex b/lib/philomena/filters.ex index 6ff7daa4..67147b0c 100644 --- a/lib/philomena/filters.ex +++ b/lib/philomena/filters.ex @@ -93,6 +93,17 @@ defmodule Philomena.Filters do |> reindex_after_update() 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 filter |> Filter.public_changeset() @@ -140,6 +151,21 @@ defmodule Philomena.Filters do Filter.changeset(filter, %{}) 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 recent_filter_ids = [user.current_filter_id | user.recent_filter_ids] @@ -174,6 +200,17 @@ defmodule Philomena.Filters do |> Enum.reverse() 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 hidden_tag_ids = Enum.uniq([tag.id | filter.hidden_tag_ids]) @@ -183,6 +220,15 @@ defmodule Philomena.Filters do |> reindex_after_update() 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 hidden_tag_ids = filter.hidden_tag_ids -- [tag.id] @@ -192,6 +238,15 @@ defmodule Philomena.Filters do |> reindex_after_update() 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 spoilered_tag_ids = Enum.uniq([tag.id | filter.spoilered_tag_ids]) @@ -201,6 +256,15 @@ defmodule Philomena.Filters do |> reindex_after_update() 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 spoilered_tag_ids = filter.spoilered_tag_ids -- [tag.id] @@ -210,38 +274,92 @@ defmodule Philomena.Filters do |> reindex_after_update() end - defp reindex_after_update({:ok, filter}) do - reindex_filter(filter) + defp reindex_after_update(result) do + case result do + {:ok, filter} -> + reindex_filter(filter) - {:ok, filter} + {:ok, filter} + + error -> + error + end end - defp reindex_after_update(error) do - error - end + @doc """ + Updates filter indexes when a user's name changes. + 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 data = FilterIndex.user_name_update_by_query(old_name, new_name) Search.update_by_query(Filter, data.query, data.set_replacements, data.replacements) 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Filters", "id", [filter.id]]) filter end + @doc """ + Removes a filter from the search index. + + ## Examples + + iex> unindex_filter(filter) + %Filter{} + + """ def unindex_filter(%Filter{} = filter) do Search.delete_document(filter.id, Filter) filter end + @doc """ + Returns a list of associations to preload when indexing filters. + + ## Examples + + iex> indexing_preloads() + [:user] + + """ def indexing_preloads do [:user] 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 Filter |> preload(^indexing_preloads()) diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index d36198b7..35bc156a 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -121,6 +121,15 @@ defmodule Philomena.Galleries do Gallery.changeset(gallery, %{}) 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 data = GalleryIndex.user_name_update_by_query(old_name, new_name) @@ -137,22 +146,65 @@ defmodule Philomena.Galleries do error 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", [gallery.id]]) gallery 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 Search.delete_document(gallery.id, Gallery) gallery end + @doc """ + Returns a list of associations to preload when indexing galleries. + + ## Examples + + iex> indexing_preloads() + [:subscribers, :creator, :interactions] + + """ def indexing_preloads do [:subscribers, :creator, :interactions] 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 Gallery |> preload(^indexing_preloads()) @@ -160,6 +212,24 @@ defmodule Philomena.Galleries do |> Search.reindex(Gallery) 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 Multi.new() |> Multi.run(:gallery, fn repo, %{} -> @@ -202,6 +272,21 @@ defmodule Philomena.Galleries do 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 Multi.new() |> Multi.run(:gallery, fn repo, %{} -> @@ -254,10 +339,35 @@ defmodule Philomena.Galleries do |> Repo.aggregate(:max, :position) 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 Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids]) + + gallery 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 gallery = get_gallery!(gallery_id) @@ -320,6 +430,8 @@ defmodule Philomena.Galleries do # Now update all the associated images Images.reindex_images(Map.keys(requested)) + + :ok end defp position_order(%{order_position_asc: true}), do: [asc: :position] diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index af0ef79f..3d69ab99 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -156,6 +156,16 @@ defmodule Philomena.Images do Logger.error("Aborting upload of #{image.id} after #{retry_count} retries") 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 image |> Repo.preload(:user) @@ -197,6 +207,18 @@ defmodule Philomena.Images do 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 if Canada.Can.can?(user, :approve, %Image{}) do Image @@ -208,12 +230,36 @@ defmodule Philomena.Images do 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 %ImageFeature{user_id: featurer.id, image_id: image.id} |> ImageFeature.changeset(%{}) |> Repo.insert() 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 image |> Image.remove_image_changeset() @@ -230,36 +276,91 @@ defmodule Philomena.Images do 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 image |> Image.lock_comments_changeset(locked) |> Repo.update() 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 image |> Image.lock_description_changeset(locked) |> Repo.update() 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 image |> Image.lock_tags_changeset(locked) |> Repo.update() 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 image |> Image.remove_hash_changeset() |> Repo.update() 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 image |> Image.scratchpad_changeset(attrs) |> Repo.update() 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 image |> Repo.preload(:source_changes) @@ -267,17 +368,49 @@ defmodule Philomena.Images do |> Repo.update() 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 Image |> where(id: ^image.id) |> Repo.update_all(set: [thumbnails_generated: false, processed: false]) Exq.enqueue(Exq, queue(image.image_mime_type), ThumbnailWorker, [image.id]) + + image end defp queue("video/webm"), do: "videos" 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 image |> Image.changeset(attrs) @@ -316,12 +449,48 @@ defmodule Philomena.Images do |> Repo.update() end + @doc """ + Updates an image's description. + + ## Examples + + iex> update_description(image, %{"description" => "New description"}) + {:ok, %Image{}} + + """ def update_description(%Image{} = image, attrs) do image |> Image.description_changeset(attrs) |> Repo.update() 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 old_sources = attrs["old_sources"] new_sources = attrs["sources"] @@ -383,6 +552,17 @@ defmodule Philomena.Images do } 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 new_tags = Tags.get_or_create_tags(attrs["tag_input"]) @@ -392,6 +572,33 @@ defmodule Philomena.Images do |> Repo.update() 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 old_tags = Tags.get_or_create_tags(attrs["old_tag_input"]) new_tags = Tags.get_or_create_tags(attrs["tag_input"]) @@ -486,14 +693,27 @@ defmodule Philomena.Images do 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 rating_changed_count = if(image.ratings_changed, do: 1, else: 0) tag_changed_count = length(image.added_tags) + length(image.removed_tags) user = attribution[:user] ip = attribution[:ip] - Limits.update_tag_count_after_update(user, ip, tag_changed_count) - Limits.update_rating_count_after_update(user, ip, rating_changed_count) + :ok = Limits.update_tag_count_after_update(user, ip, tag_changed_count) + :ok = Limits.update_rating_count_after_update(user, ip, rating_changed_count) + :ok end defp tag_change_attributes(attribution, image, tag, added, user) do @@ -518,18 +738,48 @@ defmodule Philomena.Images do } end + @doc """ + Changes the uploader of an image. + + ## Examples + + iex> update_uploader(image, %{"username" => "Admin"}) + {:ok, %Image{}} + + """ def update_uploader(%Image{} = image, attrs) do image |> Image.uploader_changeset(attrs) |> Repo.update() 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 image |> Image.anonymous_changeset(attrs) |> Repo.update() 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 image |> Image.hide_reason_changeset(attrs) @@ -545,6 +795,28 @@ defmodule Philomena.Images do 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 duplicate_reports = DuplicateReport @@ -560,6 +832,33 @@ defmodule Philomena.Images do |> process_after_hide() 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 multi = multi || Multi.new() @@ -675,6 +974,26 @@ defmodule Philomena.Images do {:ok, image} 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 key = image.hidden_image_key @@ -711,6 +1030,24 @@ defmodule Philomena.Images do 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 image_ids = Image @@ -828,24 +1165,65 @@ defmodule Philomena.Images do Image.changeset(image, %{}) 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 data = ImageIndex.user_name_update_by_query(old_name, new_name) Search.update_by_query(Image, data.query, data.set_replacements, data.replacements) 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", [image.id]]) image 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", image_ids]) image_ids 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 user_query = select(User, [u], map(u, [:id, :name])) sources_query = select(Source, [s], map(s, [:image_id, :source])) @@ -869,6 +1247,19 @@ defmodule Philomena.Images do ] 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 Image |> preload(^indexing_preloads()) @@ -876,6 +1267,17 @@ defmodule Philomena.Images do |> Search.reindex(Image) 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 files = if is_nil(hidden_key) do @@ -888,6 +1290,17 @@ defmodule Philomena.Images do Exq.enqueue(Exq, "indexing", ImagePurgeWorker, [files]) 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 {_out, 0} = System.cmd("purge-cache", [Jason.encode!(%{files: files})]) @@ -896,6 +1309,29 @@ defmodule Philomena.Images do 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 subscriptions = Subscription @@ -941,6 +1377,29 @@ defmodule Philomena.Images do {:ok, {comment_notification_count, merge_notification_count}} 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 sources = (source.sources ++ target.sources) diff --git a/lib/philomena/interactions.ex b/lib/philomena/interactions.ex index 8da603ba..8d2d9eca 100644 --- a/lib/philomena/interactions.ex +++ b/lib/philomena/interactions.ex @@ -8,6 +8,19 @@ defmodule Philomena.Interactions do alias Philomena.Repo 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), do: [] @@ -71,6 +84,18 @@ defmodule Philomena.Interactions do |> Repo.all() 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 now = DateTime.utc_now(:second) source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters]) diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 70cd5e94..70b69631 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -111,6 +111,23 @@ defmodule Philomena.Posts do Notifications.create_forum_post_notification(post.user, topic, post) 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) do @@ -176,6 +193,20 @@ defmodule Philomena.Posts do Repo.delete(post) 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 report_query = Reports.close_report_query({"Post", post.id}, user) @@ -209,6 +240,15 @@ defmodule Philomena.Posts do end end + @doc """ + Unhides a previously hidden post. + + ## Examples + + iex> unhide_post(post) + {:ok, %Post{}} + + """ def unhide_post(%Post{} = post) do post |> Post.unhide_changeset() @@ -216,6 +256,15 @@ defmodule Philomena.Posts do |> reindex_after_update() 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 post |> Post.destroy_changeset() @@ -223,6 +272,20 @@ defmodule Philomena.Posts do |> reindex_after_update() 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 report_query = Reports.close_report_query({"Post", post.id}, user) post = Post.approve_changeset(post) @@ -257,6 +320,15 @@ defmodule Philomena.Posts do Post.changeset(post, %{}) 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 data = PostIndex.user_name_update_by_query(old_name, new_name) @@ -273,12 +345,31 @@ defmodule Philomena.Posts do result 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "id", [post.id]]) post end + @doc """ + Provides preload queries for post indexing operations. + + ## Examples + + iex> indexing_preloads() + [user: user_query, topic: topic_query] + + """ def indexing_preloads do user_query = select(User, [u], map(u, [:id, :name])) @@ -293,6 +384,22 @@ defmodule Philomena.Posts do ] 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 Post |> preload(^indexing_preloads()) diff --git a/lib/philomena/slug.ex b/lib/philomena/slug.ex index 61f2baea..51cb4acb 100644 --- a/lib/philomena/slug.ex +++ b/lib/philomena/slug.ex @@ -1,29 +1,32 @@ defmodule Philomena.Slug do - # Generates a URL-safe slug from a string by removing nonessential - # information from it. - # - # The process for this is as follows: - # - # 1. Remove non-ASCII or non-printable characters. - # - # 2. Replace any runs of non-alphanumeric characters that were allowed - # through previously with hyphens. - # - # 3. Remove any starting or ending hyphens. - # - # 4. Convert all characters to their lowercase equivalents. - # - # This method makes no guarantee of creating unique slugs for unique inputs. - # In addition, for certain inputs, it will return empty strings. - # - # Example - # - # destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)") - # #=> "time-wasting-thread-3-0-sfw-no-explicit-grimdark" - # - # destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?") - # #=> "" - # + @moduledoc """ + URL-safe string shortening. + """ + + @doc """ + Generates a URL-safe slug from a string by removing nonessential + information from it. + + The process for this is as follows: + + 1. Remove non-ASCII or non-printable characters. + 2. Replace any runs of non-alphanumeric characters that were allowed + through previously with hyphens. + 3. Remove any starting or ending hyphens. + 4. Convert all characters to their lowercase equivalents. + + This method makes no guarantee of creating unique slugs for unique inputs. + In addition, for certain inputs, it will return empty strings. + + ## Example + + iex> destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)") + "time-wasting-thread-3-0-sfw-no-explicit-grimdark" + + iex> destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?") + "" + + """ @spec destructive_slug(String.t()) :: String.t() def destructive_slug(input) when is_binary(input) do input @@ -39,6 +42,11 @@ defmodule Philomena.Slug 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 string |> String.replace("-", "-dash-") diff --git a/lib/philomena/tags.ex b/lib/philomena/tags.ex index d6c6898a..7a9a79b6 100644 --- a/lib/philomena/tags.ex +++ b/lib/philomena/tags.ex @@ -24,6 +24,19 @@ defmodule Philomena.Tags do alias Philomena.DnpEntries.DnpEntry 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() def get_or_create_tags(tag_list) do tag_names = Tag.parse_tag_list(tag_list) @@ -174,6 +187,18 @@ defmodule Philomena.Tags do 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 tag |> Uploader.analyze_upload(attrs) @@ -190,6 +215,17 @@ defmodule Philomena.Tags do 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 tag |> Tag.remove_image_changeset() @@ -223,6 +259,18 @@ defmodule Philomena.Tags do {:ok, tag} 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 tag = get_tag!(tag_id) @@ -243,6 +291,19 @@ defmodule Philomena.Tags do |> Search.reindex(Image) 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 target_tag = Repo.get_by(Tag, name: String.downcase(attrs["target_tag"])) @@ -261,6 +322,19 @@ defmodule Philomena.Tags do 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 tag = get_tag!(tag_id) target_tag = get_tag!(target_tag_id) @@ -315,14 +389,36 @@ defmodule Philomena.Tags do # Finally, reindex reindex_tag_images(target_tag) reindex_tags([tag, target_tag]) + + :ok 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 Exq.enqueue(Exq, "indexing", TagReindexWorker, [tag.id]) {:ok, tag} 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 tag = get_tag!(tag_id) @@ -351,12 +447,32 @@ defmodule Philomena.Tags do |> Search.reindex(Filter) end + @doc """ + Enqueues removal of a tag alias. + + ## Examples + + iex> unalias_tag(tag) + {:ok, %Tag{}} + + """ def unalias_tag(%Tag{} = tag) do Exq.enqueue(Exq, "indexing", TagUnaliasWorker, [tag.id]) {:ok, tag} 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 tag = get_tag!(tag_id) former_alias = Repo.preload(tag, :aliased_tag).aliased_tag @@ -389,6 +505,18 @@ defmodule Philomena.Tags do |> Repo.update_all([]) 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 # Ecto bug: # ** (DBConnection.EncodeError) Postgrex expected a binary, got 5. @@ -437,22 +565,66 @@ defmodule Philomena.Tags do Tag.changeset(tag, %{}) 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", [tag.id]]) tag 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 Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", Enum.map(tags, & &1.id)]) tags 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 [:aliased_tag, :aliases, :implied_tags, :implied_by_tags] 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 Tag |> preload(^indexing_preloads()) diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 38a5d602..1f7c578e 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -138,26 +138,71 @@ defmodule Philomena.Topics do Topic.changeset(topic, %{}) 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 Topic.stick_changeset(topic) |> Repo.update() end + @doc """ + Removes sticky status from a topic. + + ## Examples + + iex> unstick_topic(topic) + {:ok, %Topic{}} + + """ def unstick_topic(topic) do Topic.unstick_changeset(topic) |> Repo.update() 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 Topic.lock_changeset(topic, attrs, user) |> Repo.update() end + @doc """ + Unlocks a topic to allow posting again. + + ## Examples + + iex> unlock_topic(topic) + {:ok, %Topic{}} + + """ def unlock_topic(%Topic{} = topic) do Topic.unlock_changeset(topic) |> Repo.update() 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 old_forum_id = topic.forum_id topic_changes = Topic.move_changeset(topic, new_forum_id) @@ -183,6 +228,15 @@ defmodule Philomena.Topics do |> Repo.transaction() 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 topic_changes = Topic.hide_changeset(topic, deletion_reason, user) @@ -205,11 +259,29 @@ defmodule Philomena.Topics do end end + @doc """ + Unhides a previously hidden topic. + + ## Examples + + iex> unhide_topic(topic) + {:ok, %Topic{}} + + """ def unhide_topic(topic) do Topic.unhide_changeset(topic) |> Repo.update() 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 topic |> Topic.title_changeset(attrs) diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index 575552aa..7c002257 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -273,6 +273,12 @@ defmodule Philomena.Users do @doc """ Unconditionally unlocks the given user. + + ## Examples + + iex> unlock_user(user) + {:ok, %User{}} + """ def unlock_user(user) do user @@ -369,6 +375,20 @@ defmodule Philomena.Users do load_with_roles(query) 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 false end @@ -503,6 +523,15 @@ defmodule Philomena.Users do 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 User.changeset(user, %{}) end @@ -544,30 +573,84 @@ defmodule Philomena.Users do defp clean_roles(nil), do: [] 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 user |> User.spoiler_type_changeset(attrs) |> Repo.update() 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 user |> User.settings_changeset(attrs) |> Repo.update() 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 user |> User.description_changeset(attrs) |> Repo.update() 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 user |> User.scratchpad_changeset(attrs) |> Repo.update() 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 watched_tag_ids = Enum.uniq([tag.id | user.watched_tag_ids]) @@ -576,6 +659,15 @@ defmodule Philomena.Users do |> Repo.update() 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 watched_tag_ids = user.watched_tag_ids -- [tag.id] @@ -584,6 +676,17 @@ defmodule Philomena.Users do |> Repo.update() 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 user |> Uploader.analyze_upload(attrs) @@ -600,6 +703,15 @@ defmodule Philomena.Users do end end + @doc """ + Removes a user's avatar. + + ## Examples + + iex> remove_avatar(user) + {:ok, %User{}} + + """ def remove_avatar(%User{} = user) do user |> User.remove_avatar_changeset() @@ -615,6 +727,17 @@ defmodule Philomena.Users do 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 old_name = user.name @@ -636,6 +759,17 @@ defmodule Philomena.Users do 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 Images.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) end + @doc """ + Reactivates a previously deactivated user account. + + ## Examples + + iex> reactivate_user(user) + {:ok, %User{}} + + """ def reactivate_user(%User{} = user) do user |> User.reactivate_changeset() |> Repo.update() 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 user |> User.deactivate_changeset(moderator) |> Repo.update() 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 user |> User.api_key_changeset() |> Repo.update() 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 user |> User.force_filter_changeset(user_params) |> Repo.update() end + @doc """ + Removes a forced filter from a user's account. + + ## Examples + + iex> unforce_filter(user) + {:ok, %User{}} + + """ def unforce_filter(%User{} = user) do user |> User.unforce_filter_changeset() |> Repo.update() end + @doc """ + Clears a user's recent filter history. + + ## Examples + + iex> clear_recent_filters(user) + {:ok, %User{}} + + """ def clear_recent_filters(%User{} = user) do user |> User.clear_recent_filters_changeset() @@ -688,18 +882,50 @@ defmodule Philomena.Users do |> setup_roles() 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 user |> User.verify_changeset() |> Repo.update() end + @doc """ + Unverifies a user, removing the automatic approval status. + + ## Examples + + iex> unverify_user(user) + {:ok, %User{}} + + """ def unverify_user(%User{} = user) do user |> User.unverify_changeset() |> Repo.update() 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 # Deactivate to prevent the user from racing these changes {:ok, user} = deactivate_user(moderator, user)