FIX: Split link watched words from replace (#13196)

It was not clear that replace watched words can be used to replace text
with URLs. This introduces a new watched word type that makes it easier
to understand.
This commit is contained in:
Bianca Nenciu 2021-06-02 08:36:49 +03:00 committed by GitHub
parent eea9fead63
commit d9484db718
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 186 additions and 62 deletions

View File

@ -9,6 +9,7 @@ export default Component.extend({
isReplace: equal("actionKey", "replace"),
isTag: equal("actionKey", "tag"),
isLink: equal("actionKey", "link"),
@discourseComputed("word.replacement")
tags(replacement) {

View File

@ -15,9 +15,16 @@ export default Component.extend({
formSubmitted: false,
actionKey: null,
showMessage: false,
selectedTags: null,
canReplace: equal("actionKey", "replace"),
canTag: equal("actionKey", "tag"),
canLink: equal("actionKey", "link"),
didInsertElement() {
this._super(...arguments);
this.set("selectedTags", []);
},
@discourseComputed("siteSettings.watched_words_regular_expressions")
placeholderKey(watchedWordsRegularExpressions) {
@ -47,6 +54,13 @@ export default Component.extend({
},
actions: {
changeSelectedTags(tags) {
this.setProperties({
selectedTags: tags,
replacement: tags.join(","),
});
},
submit() {
if (!this.isUniqueWord) {
this.setProperties({
@ -61,7 +75,10 @@ export default Component.extend({
const watchedWord = WatchedWord.create({
word: this.word,
replacement: this.canReplace || this.canTag ? this.replacement : null,
replacement:
this.canReplace || this.canTag || this.canLink
? this.replacement
: null,
action: this.actionKey,
});

View File

@ -6,15 +6,17 @@ import { equal } from "@ember/object/computed";
export default Controller.extend(ModalFunctionality, {
isReplace: equal("model.nameKey", "replace"),
isTag: equal("model.nameKey", "tag"),
isLink: equal("model.nameKey", "link"),
@discourseComputed(
"value",
"model.compiledRegularExpression",
"model.words",
"isReplace",
"isTag"
"isTag",
"isLink"
)
matches(value, regexpString, words, isReplace, isTag) {
matches(value, regexpString, words, isReplace, isTag, isLink) {
if (!value || !regexpString) {
return;
}
@ -22,7 +24,7 @@ export default Controller.extend(ModalFunctionality, {
const regexp = new RegExp(regexpString, "ig");
const matches = value.match(regexp) || [];
if (isReplace) {
if (isReplace || isLink) {
return matches.map((match) => ({
match,
replacement: words.find((word) =>

View File

@ -1,5 +1,5 @@
{{d-icon "times"}} {{word.word}}
{{#if isReplace}}
{{#if (or isReplace isLink)}}
&rarr; <span class="replacement">{{word.replacement}}</span>
{{else if isTag}}
&rarr;

View File

@ -5,15 +5,29 @@
{{#if canReplace}}
<div class="watched-word-input">
<label for="watched-replacement">{{i18n "admin.watched_words.form.replacement_label"}}</label>
{{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.replacement_placeholder"}}
<label for="watched-replacement">{{i18n "admin.watched_words.form.replace_label"}}</label>
{{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.replace_placeholder"}}
</div>
{{/if}}
{{#if canTag}}
<div class="watched-word-input">
<label for="watched-tag">{{i18n "admin.watched_words.form.tag_label"}}</label>
{{text-field id="watched-tag" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.tag_placeholder"}}
{{tag-chooser
id="watched-tag"
class="watched-word-input-field"
allowCreate=true
disabled=formSubmitted
tags=selectedTags
onChange=(action "changeSelectedTags")
}}
</div>
{{/if}}
{{#if canLink}}
<div class="watched-word-input">
<label for="watched-replacement">{{i18n "admin.watched_words.form.link_label"}}</label>
{{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.link_placeholder"}}
</div>
{{/if}}

View File

@ -5,7 +5,7 @@
<p>
{{i18n "admin.watched_words.test.found_matches"}}
<ul>
{{#if isReplace}}
{{#if (or isReplace isLink)}}
{{#each matches as |match|}}
<li>
<span class="match">{{match.match}}</span>

View File

@ -40,7 +40,7 @@
{{/if}}
{{#if showWordsList}}
<div class="watched-words-list">
<div class="watched-words-list watched-words-{{actionNameKey}}">
{{#each currentAction.words as |word| }}
<div class="watched-word-box">{{admin-watched-word actionKey=actionNameKey word=word action=(action "recordRemoved")}}</div>
{{/each}}

View File

@ -21,7 +21,8 @@ function getOpts(opts) {
customEmojiTranslation: context.site.custom_emoji_translation,
siteSettings: context.siteSettings,
formatUsername,
watchedWordsReplacements: context.site.watched_words_replace,
watchedWordsReplace: context.site.watched_words_replace,
watchedWordsLink: context.site.watched_words_link,
},
opts
);

View File

@ -1675,17 +1675,29 @@ var bar = 'bar';
test("watched words replace", function (assert) {
const opts = {
watchedWordsReplacements: { fun: "times" },
watchedWordsReplace: { fun: "times" },
};
assert.cookedOptions("test fun", opts, "<p>test times</p>");
});
test("watched words link", function (assert) {
const opts = {
watchedWordsLink: { fun: "https://discourse.org" },
};
assert.cookedOptions(
"test fun",
opts,
'<p>test <a href="https://discourse.org">fun</a></p>'
);
});
test("watched words replace with bad regex", function (assert) {
const maxMatches = 100; // same limit as MD watched-words-replace plugin
const opts = {
siteSettings: { watched_words_regular_expressions: true },
watchedWordsReplacements: { "\\bu?\\b": "you" },
watchedWordsReplace: { "\\bu?\\b": "you" },
};
assert.cookedOptions(

View File

@ -33,7 +33,8 @@ export function buildOptions(state) {
censoredRegexp,
disableEmojis,
customEmojiTranslation,
watchedWordsReplacements,
watchedWordsReplace,
watchedWordsLink,
} = state;
let features = {
@ -83,7 +84,8 @@ export function buildOptions(state) {
siteSettings.enable_advanced_editor_preview_sync,
previewing,
disableEmojis,
watchedWordsReplacements,
watchedWordsReplace,
watchedWordsLink,
};
// note, this will mutate options due to the way the API is designed

View File

@ -23,6 +23,7 @@ function findAllMatches(text, matchers) {
index: match.index,
text: match[0],
replacement: matcher.replacement,
link: matcher.link,
});
}
});
@ -32,19 +33,39 @@ function findAllMatches(text, matchers) {
export function setup(helper) {
helper.registerPlugin((md) => {
const replacements = md.options.discourse.watchedWordsReplacements;
if (!replacements) {
const matchers = [];
if (md.options.discourse.watchedWordsReplace) {
Object.entries(md.options.discourse.watchedWordsReplace).map(
([word, replacement]) => {
matchers.push({
pattern: new RegExp(word, "gi"),
replacement,
link: false,
});
}
);
}
if (md.options.discourse.watchedWordsLink) {
Object.entries(md.options.discourse.watchedWordsLink).map(
([word, replacement]) => {
matchers.push({
pattern: new RegExp(word, "gi"),
replacement,
link: true,
});
}
);
}
if (matchers.length === 0) {
return;
}
const matchers = Object.keys(replacements).map((word) => ({
pattern: new RegExp(word, "gi"),
replacement: replacements[word],
}));
const cache = {};
md.core.ruler.push("watched-words-replace", (state) => {
md.core.ruler.push("watched-words", (state) => {
for (let j = 0, l = state.tokens.length; j < l; j++) {
if (state.tokens[j].type !== "inline") {
continue;
@ -82,10 +103,6 @@ export function setup(helper) {
}
}
if (htmlLinkLevel > 0) {
continue;
}
if (currentToken.type === "text") {
const text = currentToken.content;
const matches = (cache[text] =
@ -109,25 +126,27 @@ export function setup(helper) {
nodes.push(token);
}
let url = state.md.normalizeLink(matches[ln].replacement);
if (state.md.validateLink(url) && /^https?/.test(url)) {
token = new state.Token("link_open", "a", 1);
token.attrs = [["href", url]];
token.level = level++;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
if (matches[ln].link) {
const url = state.md.normalizeLink(matches[ln].replacement);
if (htmlLinkLevel === 0 && state.md.validateLink(url)) {
token = new state.Token("link_open", "a", 1);
token.attrs = [["href", url]];
token.level = level++;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
token = new state.Token("text", "", 0);
token.content = matches[ln].text;
token.level = level;
nodes.push(token);
token = new state.Token("text", "", 0);
token.content = matches[ln].text;
token.level = level;
nodes.push(token);
token = new state.Token("link_close", "a", -1);
token.level = --level;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
token = new state.Token("link_close", "a", -1);
token.level = --level;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
}
} else {
token = new state.Token("text", "", 0);
token.content = matches[ln].replacement;

View File

@ -332,6 +332,19 @@ table.screened-ip-addresses {
vertical-align: top;
}
.watched-words-link {
.watched-word-box {
min-width: 100%;
}
}
.watched-words-replace,
.watched-words-tag {
.watched-word-box {
min-width: calc(50% - 5px);
}
}
.watched-word-box,
.watched-words-test-modal {
.replacement {
@ -354,6 +367,7 @@ table.screened-ip-addresses {
.watched-words-list {
margin-top: 20px;
display: inline-block;
width: 100%;
}
.watched-word {
@ -396,6 +410,9 @@ table.screened-ip-addresses {
input.watched-word-input-field {
min-width: 300px;
}
.select-kit.multi-select.watched-word-input-field {
width: 300px;
}
}
// Search logs

View File

@ -8,9 +8,10 @@ class WatchedWord < ActiveRecord::Base
censor: 2,
require_approval: 3,
flag: 4,
link: 8,
replace: 5,
tag: 6,
silence: 7
silence: 7,
)
end
@ -18,10 +19,16 @@ class WatchedWord < ActiveRecord::Base
before_validation do
self.word = self.class.normalize_word(self.word)
if self.action == WatchedWord.actions[:link] && !(self.replacement =~ /^https?:\/\//)
self.replacement = "#{Discourse.base_url}#{self.replacement.starts_with?("/") ? "" : "/"}#{self.replacement}"
end
end
validates :word, presence: true, uniqueness: true, length: { maximum: 100 }
validates :action, presence: true
validate :replacement_is_url, if: -> { action == WatchedWord.actions[:link] }
validates_each :word do |record, attr, val|
if WatchedWord.where(action: record.action).count >= MAX_WORDS_PER_ACTION
record.errors.add(:word, :too_many)
@ -37,6 +44,12 @@ class WatchedWord < ActiveRecord::Base
w.strip.squeeze('*')
end
def replacement_is_url
if !(replacement =~ URI::regexp)
errors.add(:base, :invalid_url)
end
end
def self.create_or_update_word(params)
new_word = normalize_word(params[:word])
w = WatchedWord.where("word ILIKE ?", new_word).first || WatchedWord.new(word: new_word)
@ -48,7 +61,7 @@ class WatchedWord < ActiveRecord::Base
end
def self.has_replacement?(action)
action == :replace || action == :tag
action == :replace || action == :tag || action == :link
end
def action_key=(arg)

View File

@ -29,7 +29,8 @@ class SiteSerializer < ApplicationSerializer
:censored_regexp,
:shared_drafts_category_id,
:custom_emoji_translation,
:watched_words_replace
:watched_words_replace,
:watched_words_link
)
has_many :categories, serializer: SiteCategorySerializer, embed: :objects
@ -185,6 +186,10 @@ class SiteSerializer < ApplicationSerializer
WordWatcher.word_matcher_regexps(:replace)
end
def watched_words_link
WordWatcher.word_matcher_regexps(:link)
end
private
def ordered_flags(flags)

View File

@ -8,7 +8,7 @@ class WordWatcher
def self.words_for_action(action)
words = WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000)
if action.to_sym == :replace || action.to_sym == :tag
if WatchedWord.has_replacement?(action.to_sym)
words.pluck(:word, :replacement).to_h
else
words.pluck(:word)
@ -31,7 +31,7 @@ class WordWatcher
def self.word_matcher_regexp(action, raise_errors: false)
words = get_cached_words(action)
if words
if action.to_sym == :replace || action.to_sym == :tag
if WatchedWord.has_replacement?(action.to_sym)
words = words.keys
end
words = words.map do |w|

View File

@ -4741,22 +4741,25 @@ en:
replace: "Replace"
tag: "Tag"
silence: "Silence"
link: "Link"
action_descriptions:
block: "Prevent posts containing these words from being posted. The user will see an error message when they try to submit their post."
censor: "Allow posts containing these words, but replace them with characters that hide the censored words."
require_approval: "Posts containing these words will require approval by staff before they can be seen."
flag: "Allow posts containing these words, but flag them as inappropriate so moderators can review them."
replace: "Replace words in posts with other words or links"
replace: "Replace words in posts with other words"
tag: "Automatically tag topics based on first post"
silence: "First posts of users containing these words will require approval by staff before they can be seen and the user will be automatically silenced."
link: "Replace words in posts with links"
form:
label: "Has word or phrase"
placeholder: "Enter word or phrase (* is a wildcard)"
placeholder_regexp: "regular expression"
replacement_label: "Replacement"
replacement_placeholder: "example or https://example.com"
replace_label: "Replacement"
replace_placeholder: "example"
tag_label: "Tag"
tag_placeholder: "tag1,tag2,tag3"
link_label: "Link"
link_placeholder: "https://example.com"
add: "Add"
success: "Success"
exists: "Already exists"

View File

@ -637,6 +637,8 @@ en:
attributes:
word:
too_many: "Too many words for that action"
base:
invalid_url: "Replacement URL is invalid"
uncategorized_category_name: "Uncategorized"

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class MigrateWatchedWordsFromReplaceToLink < ActiveRecord::Migration[6.1]
def up
execute <<~SQL
UPDATE watched_words
SET action = 8
WHERE action = 5 AND replacement ILIKE 'http%'
SQL
end
def down
execute("UPDATE watched_words SET action = 5 WHERE action = 8")
end
end

View File

@ -173,7 +173,8 @@ module PrettyText
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
__optInput.lookupUploadUrls = __lookupUploadUrls;
__optInput.censoredRegexp = #{WordWatcher.word_matcher_regexp(:censor)&.source.to_json};
__optInput.watchedWordsReplacements = #{WordWatcher.word_matcher_regexps(:replace).to_json};
__optInput.watchedWordsReplace = #{WordWatcher.word_matcher_regexps(:replace).to_json};
__optInput.watchedWordsLink = #{WordWatcher.word_matcher_regexps(:link).to_json};
JS
if opts[:topicId]

View File

@ -1403,7 +1403,7 @@ HTML
end
end
describe "watched words - replace" do
describe "watched words - replace & link" do
after(:all) { Discourse.redis.flushdb }
it "replaces words with other words" do
@ -1423,7 +1423,7 @@ HTML
end
it "replaces words with links" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "meta", replacement: "https://meta.discourse.org")
Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org")
expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML)
<p>
@ -1446,14 +1446,14 @@ HTML
end
it "supports overlapping words" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "discourse", replacement: "https://discourse.org")
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "is", replacement: "https://example.com")
Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org")
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "iz", replacement: "is")
Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "discourse", replacement: "https://discourse.org")
expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML)
expect(PrettyText.cook("Meta iz a Discourse forum")).to match_html(<<~HTML)
<p>
Meta
<a href="https://example.com" rel="noopener nofollow ugc">is</a>
a
<a href="https://meta.discourse.org" rel="noopener nofollow ugc">Meta</a>
is a
<a href="https://discourse.org" rel="noopener nofollow ugc">Discourse</a>
forum
</p>