Skip to content

Commit

Permalink
FEATURE: Watched words improvements (#7899)
Browse files Browse the repository at this point in the history
This commit contains 3 features:

- FEATURE: Allow downloading watched words
This introduces a button that allows admins to download watched words per action in a `.txt` file.

- FEATURE: Allow clearing watched words in bulk
This adds a "Clear All" button that clears all deleted words per action (e.g. block, flag etc.)

- FEATURE: List all blocked words contained in the post when it's blocked
When a post is rejected because it contains one or more blocked words, the error message now lists all the blocked words contained in the post.

-------

This also changes the format of the file for importing watched words from `.csv` to `.txt` so it becomes inconsistent with the extension of the file when watched words are exported.
  • Loading branch information
OsamaSayegh committed Jul 22, 2019
1 parent 6765032 commit f14c6d8
Show file tree
Hide file tree
Showing 17 changed files with 329 additions and 55 deletions.
Expand Up @@ -2,13 +2,13 @@ import computed from "ember-addons/ember-computed-decorators";
import UploadMixin from "discourse/mixins/upload";

export default Ember.Component.extend(UploadMixin, {
type: "csv",
type: "txt",
classNames: "watched-words-uploader",
uploadUrl: "/admin/logs/watched_words/upload",
addDisabled: Ember.computed.alias("uploading"),

validateUploadedFilesOptions() {
return { csvOnly: true };
return { skipValidation: true };
},

@computed("actionKey")
Expand Down
@@ -1,5 +1,7 @@
import computed from "ember-addons/ember-computed-decorators";
import WatchedWord from "admin/models/watched-word";
import { ajax } from "discourse/lib/ajax";
import { fmt } from "discourse/lib/computed";

export default Ember.Controller.extend({
actionNameKey: null,
Expand All @@ -8,6 +10,10 @@ export default Ember.Controller.extend({
"adminWatchedWords.filtered",
"adminWatchedWords.showWords"
),
downloadLink: fmt(
"actionNameKey",
"/admin/logs/watched_words/action/%@/download"
),

findAction(actionName) {
return (this.get("adminWatchedWords.model") || []).findBy(
Expand All @@ -17,24 +23,23 @@ export default Ember.Controller.extend({
},

@computed("actionNameKey", "adminWatchedWords.model")
filteredContent(actionNameKey) {
if (!actionNameKey) {
return [];
}
currentAction(actionName) {
return this.findAction(actionName);
},

const a = this.findAction(actionNameKey);
return a ? a.words : [];
@computed("currentAction.words.[]", "adminWatchedWords.model")
filteredContent(words) {
return words || [];
},

@computed("actionNameKey")
actionDescription(actionNameKey) {
return I18n.t("admin.watched_words.action_descriptions." + actionNameKey);
},

@computed("actionNameKey", "adminWatchedWords.model")
wordCount(actionNameKey) {
const a = this.findAction(actionNameKey);
return a ? a.words.length : 0;
@computed("currentAction.count")
wordCount(count) {
return count || 0;
},

actions: {
Expand Down Expand Up @@ -62,17 +67,40 @@ export default Ember.Controller.extend({
},

recordRemoved(arg) {
const a = this.findAction(this.actionNameKey);
if (a) {
a.words.removeObject(arg);
a.decrementProperty("count");
if (this.currentAction) {
this.currentAction.words.removeObject(arg);
this.currentAction.decrementProperty("count");
}
},

uploadComplete() {
WatchedWord.findAll().then(data => {
this.set("adminWatchedWords.model", data);
});
},

clearAll() {
const actionKey = this.actionNameKey;
bootbox.confirm(
I18n.t(`admin.watched_words.clear_all_confirm_${actionKey}`),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
ajax(`/admin/logs/watched_words/action/${actionKey}.json`, {
method: "DELETE"
}).then(() => {
const action = this.findAction(actionKey);
if (action) {
action.setProperties({
words: [],
count: 0
});
}
});
}
}
);
}
}
});
@@ -1,7 +1,6 @@
<label class="btn btn-default {{if addDisabled 'disabled'}}">
{{d-icon "upload"}}
{{i18n 'admin.watched_words.form.upload'}}
<input class="hidden-upload-field" disabled={{addDisabled}} type="file" accept="text/plain,text/csv" />
<input class="hidden-upload-field" disabled={{addDisabled}} type="file" accept="text/plain" />
</label>
<br/>
<span class="instructions">{{i18n 'admin.watched_words.one_word_per_line'}}</span>
30 changes: 24 additions & 6 deletions app/assets/javascripts/admin/templates/watched-words-action.hbs
Expand Up @@ -3,14 +3,24 @@
<p class="about">{{actionDescription}}</p>

<div class="watched-word-controls">
{{watched-word-form
actionKey=actionNameKey
action=(action "recordAdded")
filteredContent=filteredContent
regularExpressions=adminWatchedWords.regularExpressions}}
{{watched-word-form
actionKey=actionNameKey
action=(action "recordAdded")
filteredContent=filteredContent
regularExpressions=adminWatchedWords.regularExpressions}}

{{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}}
<div class="download-upload-controls">
<div class="download">
{{d-button
class="btn-default download-link"
href=downloadLink
icon="download"
label="admin.watched_words.download"}}
</div>
{{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}}
</div>
</div>

<div>
<label class="show-words-checkbox">
{{input type="checkbox" checked=adminWatchedWords.showWords disabled=adminWatchedWords.disableShowWords}}
Expand All @@ -26,3 +36,11 @@
{{i18n 'admin.watched_words.word_count' count=wordCount}}
{{/if}}
</div>

<div class="clear-all-row">
{{d-button
class="btn-danger clear-all"
label="admin.watched_words.clear_all"
icon="trash-alt"
action=(action "clearAll")}}
</div>
3 changes: 2 additions & 1 deletion app/assets/javascripts/discourse/lib/url.js.es6
Expand Up @@ -23,7 +23,8 @@ const SERVER_SIDE_ONLY = [
/\.rss$/,
/\.json$/,
/^\/admin\/upgrade$/,
/^\/logs($|\/)/
/^\/logs($|\/)/,
/^\/admin\/logs\/watched_words\/action\/[^\/]+\/download$/
];

export function rewritePath(path) {
Expand Down
24 changes: 22 additions & 2 deletions app/assets/stylesheets/common/admin/staff_logs.scss
Expand Up @@ -362,17 +362,33 @@ table.screened-ip-addresses {
display: inline-block;
width: 250px;
margin-bottom: 1em;
float: left;
vertical-align: top;
}

.admin-watched-words {
.clear-all-row {
display: flex;
margin-top: 10px;
justify-content: flex-end;
}
}

.watched-word-controls {
display: flex;
flex-wrap: wrap;
margin-bottom: 1em;
justify-content: space-between;
.download-upload-controls {
display: flex;
}
.download {
justify-content: flex-end;
}
}

.watched-words-list {
margin-top: 20px;
display: inline-block;
}

.watched-word {
Expand All @@ -395,13 +411,17 @@ table.screened-ip-addresses {
}

.watched-words-uploader {
margin-left: auto;
margin-left: 5px;
display: flex;
flex-direction: column;
align-items: flex-end;
@media screen and (max-width: 500px) {
flex: 1 1 100%;
margin-top: 0.5em;
}
.instructions {
font-size: $font-down-1;
margin-top: 5px;
}
}

Expand Down
27 changes: 26 additions & 1 deletion app/controllers/admin/watched_words_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true

class Admin::WatchedWordsController < Admin::AdminController
skip_before_action :check_xhr, only: [:download]

def index
render_json_dump WatchedWordListSerializer.new(WatchedWord.by_action, scope: guardian, root: false)
Expand Down Expand Up @@ -35,12 +36,36 @@ def upload
rescue => e
data = failed_json.merge(errors: [e.message])
end
MessageBus.publish("/uploads/csv", data.as_json, client_ids: [params[:client_id]])
MessageBus.publish("/uploads/txt", data.as_json, client_ids: [params[:client_id]])
end

render json: success_json
end

def download
params.require(:id)
name = watched_words_params[:id].to_sym
action = WatchedWord.actions[name]
raise Discourse::NotFound if !action

content = WatchedWord.where(action: action).pluck(:word).join("\n")
headers['Content-Length'] = content.bytesize.to_s
send_data content,
filename: "#{Discourse.current_hostname}-watched-words-#{name}.txt",
content_type: "text/plain"
end

def clear_all
params.require(:id)
name = watched_words_params[:id].to_sym
action = WatchedWord.actions[name]
raise Discourse::NotFound if !action

WatchedWord.where(action: action).delete_all
WordWatcher.clear_cache!
render json: success_json
end

private

def watched_words_params
Expand Down
1 change: 1 addition & 0 deletions app/controllers/export_csv_controller.rb
Expand Up @@ -12,6 +12,7 @@ def export_entity
end

private

def export_params
@_export_params ||= begin
params.require(:entity)
Expand Down
61 changes: 47 additions & 14 deletions app/services/word_watcher.rb
Expand Up @@ -14,17 +14,27 @@ def self.words_for_action_exists?(action)
WatchedWord.where(action: WatchedWord.actions[action.to_sym]).exists?
end

def self.get_cached_words(action)
Discourse.cache.fetch(word_matcher_regexp_key(action), expires_in: 1.day) do
words_for_action(action).presence
end
end

def self.word_matcher_regexp(action)
s = Discourse.cache.fetch(word_matcher_regexp_key(action), expires_in: 1.day) do
words = words_for_action(action)
if words.empty?
nil
else
regexp = '(' + words.map { |w| word_to_regexp(w) }.join('|'.freeze) + ')'
SiteSetting.watched_words_regular_expressions? ? regexp : "(?<!\\w)(#{regexp})(?!\\w)"
words = get_cached_words(action)
if words
words = words.map do |w|
word = word_to_regexp(w)
word = "(#{word})" if SiteSetting.watched_words_regular_expressions?
word
end
regexp = words.join('|')
if !SiteSetting.watched_words_regular_expressions?
regexp = "(#{regexp})"
regexp = "(?<!\\w)(#{regexp})(?!\\w)"
end
Regexp.new(regexp, Regexp::IGNORECASE)
end
s.present? ? Regexp.new(s, Regexp::IGNORECASE) : nil
end

def self.word_to_regexp(word)
Expand All @@ -37,7 +47,7 @@ def self.word_to_regexp(word)
end

def self.word_matcher_regexp_key(action)
"watched-words-regexp:#{action}"
"watched-words-list:#{action}"
end

def self.clear_cache!
Expand All @@ -55,12 +65,35 @@ def should_flag?
end

def should_block?
word_matches_for_action?(:block)
word_matches_for_action?(:block, all_matches: true)
end

def word_matches_for_action?(action)
r = self.class.word_matcher_regexp(action)
r ? r.match(@raw) : false
end
def word_matches_for_action?(action, all_matches: false)
regexp = self.class.word_matcher_regexp(action)
if regexp
match = regexp.match(@raw)
return match if !all_matches || !match

if SiteSetting.watched_words_regular_expressions?
set = Set.new
@raw.scan(regexp).each do |m|
if Array === m
set.add(m.find(&:present?))
elsif String === m
set.add(m)
end
end
matches = set.to_a
else
matches = @raw.scan(regexp)
matches.flatten!
matches.uniq!
end
matches.compact!
matches.sort!
matches
else
false
end
end
end
8 changes: 7 additions & 1 deletion config/locales/client.en.yml
Expand Up @@ -3874,6 +3874,12 @@ en:
clear_filter: "Clear"
show_words: "show words"
one_word_per_line: "One word per line"
download: Download
clear_all: Clear All
clear_all_confirm_block: "Are you sure you want to clear all watched words for the Block action?"
clear_all_confirm_censor: "Are you sure you want to clear all watched words for the Censor action?"
clear_all_confirm_flag: "Are you sure you want to clear all watched words for the Flag action?"
clear_all_confirm_require_approval: "Are you sure you want to clear all watched words for the Require Approval action?"
word_count:
one: "%{count} word"
other: "%{count} words"
Expand All @@ -3894,7 +3900,7 @@ en:
add: "Add"
success: "Success"
exists: "Already exists"
upload: "Upload"
upload: "Add from file"
upload_successful: "Upload successful. Words have been added."

impersonate:
Expand Down

0 comments on commit f14c6d8

Please sign in to comment.