diff --git a/app/controllers/settings/exports/filters_controller.rb b/app/controllers/settings/exports/filters_controller.rb new file mode 100644 index 00000000000..17f1af10e5d --- /dev/null +++ b/app/controllers/settings/exports/filters_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class FiltersController < BaseController + include Settings::ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_filters_csv + end + end + end +end diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index be1699315f6..e3c4834aac1 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -13,6 +13,7 @@ class Settings::ImportsController < Settings::BaseController domain_blocking: 'blocked_domains_failures.csv', bookmarks: 'bookmarks_failures.csv', lists: 'lists_failures.csv', + filters: 'filters_failures.csv', }.freeze TYPE_TO_HEADERS_MAP = { @@ -22,6 +23,7 @@ class Settings::ImportsController < Settings::BaseController domain_blocking: false, bookmarks: false, lists: false, + filters: false, }.freeze RECENT_IMPORTS_LIMIT = 10 @@ -55,6 +57,8 @@ class Settings::ImportsController < Settings::BaseController csv << [row.data['uri']] when :lists csv << [row.data['list_name'], row.data['acct']] + when :filters + csv << [row.data['title'], row.data['context'], row.data['keywords'], row.data['whole_word'], row.data['action'], row.data['expires_at']] end end end diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index e3e46d7b1c6..9b873155616 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -34,6 +34,7 @@ class BulkImport < ApplicationRecord domain_blocking: 3, bookmarks: 4, lists: 5, + filters: 6, } enum :state, { diff --git a/app/models/export.rb b/app/models/export.rb index 6ed9f60c7c8..9fdb5864752 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -55,6 +55,14 @@ class Export end end + def to_filters_csv + CSV.generate(headers: ['Title', 'Context', 'Keywords', 'Whole Word', 'Action', 'Expire after'], write_headers: true) do |csv| + account.custom_filters.reorder(:title).each do |filter| + csv << [filter.title, filter.context, filter.keywords.map(&:keyword), filter.keywords.map(&:whole_word), filter.action, filter.expires_at] + end + end + end + private def to_csv(accounts) diff --git a/app/models/form/import.rb b/app/models/form/import.rb index 3cc4af064ff..05f638654f3 100644 --- a/app/models/form/import.rb +++ b/app/models/form/import.rb @@ -19,6 +19,7 @@ class Form::Import domain_blocking: ['#domain'], bookmarks: ['#uri'], lists: ['List name', 'Account address'], + filters: ['Title', 'Context', 'Keywords', 'Whole Word', 'Action', 'Expire after'], }.freeze KNOWN_FIRST_HEADERS = EXPECTED_HEADERS_BY_TYPE.values.map(&:first).uniq.freeze @@ -32,6 +33,14 @@ class Form::Import '#domain' => 'domain', '#uri' => 'uri', 'List name' => 'list_name', + + # Filters + 'Title' => 'title', + 'Context' => 'context', + 'Keywords' => 'keywords', + 'Whole Word' => 'whole_word', + 'Action' => 'action', + 'Expire after' => 'expires_at', }.freeze class EmptyFileError < StandardError; end @@ -55,6 +64,8 @@ class Form::Import :bookmarks elsif file_name_matches?('lists') :lists + elsif file_name_matches?('filters') || csv_headers_match?('Keywords') + :filters end end @@ -102,6 +113,8 @@ class Form::Import ['#uri'] when :lists ['List name', 'Account address'] + when :filters + ['Title', 'Context', 'Keywords', 'Whole Word', 'Action', 'Expire after'] end end @@ -109,19 +122,31 @@ class Form::Import return @csv_data if defined?(@csv_data) csv_converter = lambda do |field, field_info| - case field_info.header - when 'Show boosts', 'Notify on new posts', 'Hide notifications' - ActiveModel::Type::Boolean.new.cast(field&.downcase) - when 'Languages' - field&.split(',')&.map(&:strip)&.presence - when 'Account address' - field.strip.gsub(/\A@/, '') - when '#domain' - field&.strip&.downcase - when '#uri', 'List name' - field.strip + case type.to_sym + when :filters + case field_info.header + when 'Context', 'Keywords', 'Whole Word' + Oj.load(field) + when 'Expire after' + field.blank? ? nil : Time.zone.parse(field) + else + field + end else - field + case field_info.header + when 'Show boosts', 'Notify on new posts', 'Hide notifications' + ActiveModel::Type::Boolean.new.cast(field&.downcase) + when 'Languages' + field&.split(',')&.map(&:strip)&.presence + when 'Account address' + field.strip.gsub(/\A@/, '') + when '#domain' + field&.strip&.downcase + when '#uri', 'List name' + field.strip + else + field + end end end diff --git a/app/presenters/export_summary.rb b/app/presenters/export_summary.rb index 8e45aadf67d..32af0c3302c 100644 --- a/app/presenters/export_summary.rb +++ b/app/presenters/export_summary.rb @@ -10,6 +10,7 @@ class ExportSummary :owned_lists, :media_attachments, :muting, + :custom_filters, to: :account, prefix: true ) @@ -47,6 +48,10 @@ class ExportSummary counts[:muting].value end + def total_filters + counts[:filters].value + end + def total_statuses account.statuses_count end @@ -64,6 +69,7 @@ class ExportSummary domain_blocks: account_domain_blocks.async_count, owned_lists: account_owned_lists.async_count, muting: account_muting.async_count, + filters: account_custom_filters.async_count, storage: account_media_attachments.async_sum(:file_file_size), } end diff --git a/app/services/bulk_import_row_service.rb b/app/services/bulk_import_row_service.rb index ac5080f0ba4..887cf83b470 100644 --- a/app/services/bulk_import_row_service.rb +++ b/app/services/bulk_import_row_service.rb @@ -42,6 +42,13 @@ class BulkImportRowService FollowService.new.call(@account, @target_account) unless @account.id == @target_account.id list.accounts << @target_account + when :filters + filter = @account.custom_filters.find_or_initialize_by(title: @data['title']) + filter.context = @data['context'] + filter.keywords = @data['keywords'].map.with_index { |keyword, i| CustomFilterKeyword.new(keyword: keyword, whole_word: @data['whole_word'][i]) } + filter.action = @data['action'].to_sym + filter.expires_at = @data['expires_at'] + filter.save! end true diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb index a361c7a3dac..471c3f94caf 100644 --- a/app/services/bulk_import_service.rb +++ b/app/services/bulk_import_service.rb @@ -18,6 +18,10 @@ class BulkImportService < BaseService import_bookmarks! when :lists import_lists! + when :filters + import_filters! + else + raise NotImplementedError, "Unknown import type: #{@import.type}" end @import.update!(state: :finished, finished_at: Time.now.utc) if @import.processed_items == @import.total_items @@ -182,4 +186,14 @@ class BulkImportService < BaseService [row.id] end end + + def import_filters! + rows = @import.rows.to_a + + @account.custom_filters.destroy_all if @import.overwrite? + + Import::RowWorker.push_bulk(rows) do |row| + [row.id] + end + end end diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml index 5a151be73be..eb1421021cf 100644 --- a/app/views/settings/exports/show.html.haml +++ b/app/views/settings/exports/show.html.haml @@ -40,6 +40,10 @@ %th= t('exports.bookmarks') %td= number_with_delimiter @export_summary.total_bookmarks %td= table_link_to 'download', t('exports.csv'), settings_exports_bookmarks_path(format: :csv) + %tr + %th= t('exports.filters') + %td= number_with_delimiter @export_summary.total_filters + %td= table_link_to 'download', t('exports.csv'), settings_exports_filters_path(format: :csv) %hr.spacer/ diff --git a/app/views/settings/imports/index.html.haml b/app/views/settings/imports/index.html.haml index 55421991e13..ec07180326f 100644 --- a/app/views/settings/imports/index.html.haml +++ b/app/views/settings/imports/index.html.haml @@ -5,7 +5,7 @@ .field-group = f.input :type, as: :grouped_select, - collection: { constructive: %i(following bookmarks lists), destructive: %i(muting blocking domain_blocking) }, + collection: { constructive: %i(following bookmarks lists), destructive: %i(filters muting blocking domain_blocking) }, group_label_method: ->(group) { I18n.t("imports.type_groups.#{group.first}") }, group_method: :last, hint: t('imports.preface'), diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index b934696bda6..39b08cc9de4 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -66,8 +66,8 @@ ignore_unused: - 'admin_mailer.*.subject' - 'user_mailer.*.subject' - 'notification_mailer.*' - - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks,lists}_html.*' - - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks,lists}_html.*' + - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks,lists,filters}_html.*' + - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks,lists,filters}_html.*' - 'mail_subscriptions.unsubscribe.emails.*' - 'preferences.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use - 'edit_profile.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use diff --git a/config/locales/en.yml b/config/locales/en.yml index ebbb72fb073..6d28a4df6f2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1408,6 +1408,7 @@ en: bookmarks: Bookmarks csv: CSV domain_blocks: Domain blocks + filters: Filters lists: Lists mutes: You mute storage: Media storage @@ -1507,6 +1508,9 @@ en: domain_blocking_html: one: You are about to replace your domain block list with up to %{count} domain from %{filename}. other: You are about to replace your domain block list with up to %{count} domains from %{filename}. + filters_html: + one: You are about to replace your filters with up to %{count} filter from %{filename}. + other: You are about to replace your filters with up to %{count} filters from %{filename}. following_html: one: You are about to follow up to %{count} account from %{filename} and stop following anyone else. other: You are about to follow up to %{count} accounts from %{filename} and stop following anyone else. @@ -1526,6 +1530,9 @@ en: domain_blocking_html: one: You are about to block up to %{count} domain from %{filename}. other: You are about to block up to %{count} domains from %{filename}. + filters_html: + one: You are about to add up to %{count} filter from %{filename}. + other: You are about to add up to %{count} filters from %{filename}. following_html: one: You are about to follow up to %{count} account from %{filename}. other: You are about to follow up to %{count} accounts from %{filename}. @@ -1560,6 +1567,7 @@ en: blocking: Blocking list bookmarks: Bookmarks domain_blocking: Domain blocking list + filters: Filters following: Following list lists: Lists muting: Muting list diff --git a/config/routes/settings.rb b/config/routes/settings.rb index f5869a767c2..a1ea350ab37 100644 --- a/config/routes/settings.rb +++ b/config/routes/settings.rb @@ -30,6 +30,7 @@ namespace :settings do resources :lists, only: :index resources :domain_blocks, only: :index, controller: :blocked_domains resources :bookmarks, only: :index + resources :filters, only: :index end resources :two_factor_authentication_methods, only: [:index] do diff --git a/spec/controllers/settings/imports_controller_spec.rb b/spec/controllers/settings/imports_controller_spec.rb index c2c6c353f31..718b84c828f 100644 --- a/spec/controllers/settings/imports_controller_spec.rb +++ b/spec/controllers/settings/imports_controller_spec.rb @@ -229,6 +229,18 @@ RSpec.describe Settings::ImportsController do it_behaves_like 'export failed rows', "Amigos,user@example.com\nFrenemies,user@org.org\n" end + + context 'with filters' do + let(:import_type) { 'filters' } + + let(:rows) do + [ + { 'title' => 'Invalid Filter', 'context' => ['waffles'], 'keywords' => ['mean stuff'], 'whole_word' => [true], 'action' => 'hide', 'expires_at' => nil }, + ] + end + + it_behaves_like 'export failed rows', "Invalid Filter,\"[\"\"waffles\"\"]\",\"[\"\"mean stuff\"\"]\",[true],hide,\n" + end end describe 'POST #create' do @@ -277,6 +289,8 @@ RSpec.describe Settings::ImportsController do it_behaves_like 'successful import', 'domain_blocking', 'domain_blocks.csv', 'overwrite' it_behaves_like 'successful import', 'bookmarks', 'bookmark-imports.txt', 'merge' it_behaves_like 'successful import', 'bookmarks', 'bookmark-imports.txt', 'overwrite' + it_behaves_like 'successful import', 'filters', 'filters.csv', 'merge' + it_behaves_like 'successful import', 'filters', 'filters.csv', 'overwrite' it_behaves_like 'unsuccessful import', 'following', 'domain_blocks.csv', 'merge' it_behaves_like 'unsuccessful import', 'following', 'domain_blocks.csv', 'overwrite' @@ -284,6 +298,8 @@ RSpec.describe Settings::ImportsController do it_behaves_like 'unsuccessful import', 'blocking', 'domain_blocks.csv', 'overwrite' it_behaves_like 'unsuccessful import', 'muting', 'domain_blocks.csv', 'merge' it_behaves_like 'unsuccessful import', 'muting', 'domain_blocks.csv', 'overwrite' + it_behaves_like 'unsuccessful import', 'filters', 'domain_blocks.csv', 'merge' + it_behaves_like 'unsuccessful import', 'filters', 'domain_blocks.csv', 'overwrite' it_behaves_like 'unsuccessful import', 'following', 'empty.csv', 'merge' it_behaves_like 'unsuccessful import', 'following', 'empty.csv', 'overwrite' diff --git a/spec/fixtures/files/filters.csv b/spec/fixtures/files/filters.csv new file mode 100644 index 00000000000..d554c787569 --- /dev/null +++ b/spec/fixtures/files/filters.csv @@ -0,0 +1,4 @@ +Title,Context,Keywords,Whole Word,Action,Expire after +current events,"[""home"", ""public"", ""account""]","[""minions song contest""]",[true],warn,2025-08-14 21:33:29 UTC +unwanted solicitations,"[""notifications"", ""thread""]","[""wizard school enrolment""]",[false],hide, +scary things,"[""home"", ""public""]","[""flying sharks"", ""sapient crabs""]","[true, false]",blur, \ No newline at end of file diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 81aaf885855..4652aed4e4c 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -103,4 +103,23 @@ RSpec.describe Export do ) end end + + describe '#to_filters_csv' do + before do + Fabricate.times(2, :custom_filter, account: account) do + keywords { [Fabricate(:custom_filter_keyword)] } + end + end + + let(:export) { CSV.parse(subject.to_filters_csv) } + + it 'returns a csv of custom filters' do + expect(export) + .to contain_exactly( + contain_exactly('Title', 'Context', 'Keywords', 'Whole Word', 'Action', 'Expire after'), + include('discourse', '["home", "notifications"]', '["discourse"]', '[true]', 'warn', be_blank), + include(be_present) + ) + end + end end diff --git a/spec/models/form/import_spec.rb b/spec/models/form/import_spec.rb index d682e13ecb9..bb781b49f78 100644 --- a/spec/models/form/import_spec.rb +++ b/spec/models/form/import_spec.rb @@ -282,6 +282,12 @@ RSpec.describe Form::Import do { 'acct' => 'foo@example.com', 'list_name' => 'test' }, ] + it_behaves_like 'on successful import', 'filters', 'merge', 'filters.csv', [ + { 'title' => 'current events', 'context' => %w(home public account), 'keywords' => ['minions song contest'], 'whole_word' => [true], 'action' => 'warn', 'expires_at' => Time.zone.parse('2025-08-14 21:33:29 UTC') }, + { 'title' => 'unwanted solicitations', 'context' => %w(notifications thread), 'keywords' => ['wizard school enrolment'], 'whole_word' => [false], 'action' => 'hide', 'expires_at' => nil }, + { 'title' => 'scary things', 'context' => %w(home public), 'keywords' => ['flying sharks', 'sapient crabs'], 'whole_word' => [true, false], 'action' => 'blur', 'expires_at' => nil }, + ] + # Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users # # https://github.com/mastodon/mastodon/issues/20571