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:
parent
eea9fead63
commit
d9484db718
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{{d-icon "times"}} {{word.word}}
|
||||
{{#if isReplace}}
|
||||
{{#if (or isReplace isLink)}}
|
||||
→ <span class="replacement">{{word.replacement}}</span>
|
||||
{{else if isTag}}
|
||||
→
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue