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