FEATURE: add HTML replacements

This adds support for Watched Words to allow replacement with HTML content rather than always replacing with text.

Can be useful when automatically replacing with the '<abbr>' tag for example.

Discussion - https://meta.discourse.org/t/replace-text-with-more-than-just-links/305672
This commit is contained in:
Régis Hanol 2024-05-06 17:08:34 +02:00
parent 9b70cbf4bb
commit 1eec8c3fa6
13 changed files with 65 additions and 22 deletions

View File

@ -18,3 +18,6 @@
"admin.watched_words.case_sensitive"
}}</span>
{{/if}}
{{#if this.isHtml}}
<span class="html">{{i18n "admin.watched_words.html"}}</span>
{{/if}}

View File

@ -11,12 +11,10 @@ export default class AdminWatchedWord extends Component {
@service dialog;
@equal("actionKey", "replace") isReplace;
@equal("actionKey", "tag") isTag;
@equal("actionKey", "link") isLink;
@alias("word.case_sensitive") isCaseSensitive;
@alias("word.html") isHtml;
@discourseComputed("word.replacement")
tags(replacement) {

View File

@ -75,6 +75,20 @@
</label>
</div>
<div class="watched-word-input">
<label for="watched-html">{{i18n
"admin.watched_words.form.html_label"
}}</label>
<label class="html-checkbox checkbox-label">
<Input
@type="checkbox"
@checked={{this.isHtml}}
disabled={{this.formSubmitted}}
/>
{{i18n "admin.watched_words.form.html_description"}}
</label>
</div>
<DButton
@action={{this.submitForm}}
@disabled={{this.submitDisabled}}

View File

@ -19,15 +19,13 @@ export default class WatchedWordForm extends Component {
actionKey = null;
showMessage = false;
isCaseSensitive = false;
isHtml = false;
selectedTags = [];
words = [];
@empty("words") submitDisabled;
@equal("actionKey", "replace") canReplace;
@equal("actionKey", "tag") canTag;
@equal("actionKey", "link") canLink;
@discourseComputed("siteSettings.watched_words_regular_expressions")
@ -102,6 +100,7 @@ export default class WatchedWordForm extends Component {
: null,
action: this.actionKey,
isCaseSensitive: this.isCaseSensitive,
isHtml: this.isHtml,
});
watchedWord
@ -114,6 +113,7 @@ export default class WatchedWordForm extends Component {
showMessage: true,
message: I18n.t("admin.watched_words.form.success"),
isCaseSensitive: false,
isHtml: false,
});
if (result.words) {
result.words.forEach((word) => {

View File

@ -38,6 +38,7 @@ export default class WatchedWord extends EmberObject {
replacement: this.replacement,
action_key: this.action,
case_sensitive: this.isCaseSensitive,
html: this.isHtml,
},
dataType: "json",
}

View File

@ -16,7 +16,7 @@ function isLinkClose(str) {
function findAllMatches(text, matchers) {
const matches = [];
for (const { word, pattern, replacement, link } of matchers) {
for (const { word, pattern, replacement, link, html } of matchers) {
if (matches.length >= MAX_MATCHES) {
break;
}
@ -28,6 +28,7 @@ function findAllMatches(text, matchers) {
text: match[1],
replacement,
link,
html,
});
if (matches.length >= MAX_MATCHES) {
@ -65,6 +66,7 @@ export function setup(helper) {
pattern: createWatchedWordRegExp(word),
replacement: options.replacement,
link: false,
html: options.html,
});
}
);
@ -239,7 +241,8 @@ export function setup(helper) {
nodes.push(token);
}
} else {
token = new state.Token("text", "", 0);
let tokenType = matches[ln].html ? "html_inline" : "text";
token = new state.Token(tokenType, "", 0);
token.content = matches[ln].replacement;
token.level = level;
nodes.push(token);

View File

@ -120,6 +120,6 @@ class Admin::WatchedWordsController < Admin::StaffController
def watched_words_params
@watched_words_params ||=
params.permit(:id, :replacement, :action_key, :case_sensitive, words: [])
params.permit(:id, :replacement, :action_key, :case_sensitive, :html, words: [])
end
end

View File

@ -16,6 +16,7 @@ class WatchedWord < ActiveRecord::Base
validates :action, presence: true
validate :replacement_is_url, if: -> { action == WatchedWord.actions[:link] }
validate :replacement_is_tag_list, if: -> { action == WatchedWord.actions[:tag] }
validate :replacement_is_html, if: -> { replacement.present? && html? }
validates_each :word do |record, attr, val|
if WatchedWord.where(action: record.action).count >= MAX_WORDS_PER_ACTION
@ -65,6 +66,7 @@ class WatchedWord < ActiveRecord::Base
word.action_key = params[:action_key] if params[:action_key]
word.action = params[:action] if params[:action]
word.case_sensitive = params[:case_sensitive] if !params[:case_sensitive].nil?
word.html = params[:html] if params[:html]
word.watched_word_group_id = params[:watched_word_group_id]
word.save
word
@ -79,11 +81,7 @@ class WatchedWord < ActiveRecord::Base
end
def action_log_details
if replacement.present?
"#{word}#{replacement}"
else
word
end
replacement.present? ? "#{word}#{replacement}" : word
end
private
@ -107,6 +105,10 @@ class WatchedWord < ActiveRecord::Base
errors.add(:base, :invalid_tag_list)
end
end
def replacement_is_html
errors.add(:base, :invalid_html) if action != WatchedWord.actions[:replace]
end
end
# == Schema Information
@ -121,6 +123,7 @@ end
# replacement :string
# case_sensitive :boolean default(FALSE), not null
# watched_word_group_id :bigint
# html :boolean default(FALSE), not null
#
# Indexes
#

View File

@ -1,7 +1,14 @@
# frozen_string_literal: true
class WatchedWordSerializer < ApplicationSerializer
attributes :id, :word, :regexp, :replacement, :action, :case_sensitive, :watched_word_group_id
attributes :id,
:word,
:regexp,
:replacement,
:action,
:case_sensitive,
:watched_word_group_id,
:html
def regexp
WordWatcher.word_to_regexp(word, engine: :js)
@ -14,4 +21,8 @@ class WatchedWordSerializer < ApplicationSerializer
def include_replacement?
WatchedWord.has_replacement?(action)
end
def include_html?
object.action == WatchedWord.actions[:replace] && object.html
end
end

View File

@ -31,12 +31,11 @@ class WordWatcher
.where(action: WatchedWord.actions[action.to_sym])
.limit(WatchedWord::MAX_WORDS_PER_ACTION)
.order(:id)
.pluck(:word, :replacement, :case_sensitive)
.to_h do |w, r, c|
[
word_to_regexp(w, match_word: false),
{ word: w, replacement: r, case_sensitive: c }.compact,
]
.pluck(:word, :replacement, :case_sensitive, :html)
.to_h do |w, r, c, h|
opts = { word: w, replacement: r, case_sensitive: c }.compact
opts[:html] = true if h
[word_to_regexp(w, match_word: false), opts]
end
end

View File

@ -4766,7 +4766,7 @@ en:
clear_filter: "Clear filter"
no_results:
title: "No results"
description: "We couldnt find anything matching %{filter}.<br><br>Did you want to <a class=\"sidebar-additional-filter-settings\" href=\"%{settings_filter_url}\">search site settings</a> or the <a class=\"sidebar-additional-filter-users\" href=\"%{user_list_filter_url}\">admin user list?</a>"
description: 'We couldnt find anything matching %{filter}.<br><br>Did you want to <a class="sidebar-additional-filter-settings" href="%{settings_filter_url}">search site settings</a> or the <a class="sidebar-additional-filter-users" href="%{user_list_filter_url}">admin user list?</a>'
welcome_topic_banner:
title: "Create your Welcome Topic"
@ -6102,6 +6102,7 @@ en:
one: "show %{count} word"
other: "show %{count} words"
case_sensitive: "(case-sensitive)"
html: "(html)"
download: Download
clear_all: Clear All
clear_all_confirm: "Are you sure you want to clear all watched words for the %{action} action?"
@ -6141,6 +6142,8 @@ en:
upload_successful: "Upload successful. Words have been added."
case_sensitivity_label: "Is case-sensitive"
case_sensitivity_description: "Only words with matching character casing"
html_label: "HTML"
html_description: "Outputs HTML in the replacement"
words_or_phrases: "words or phrases"
test:
button_label: "Test"

View File

@ -800,6 +800,7 @@ en:
base:
invalid_url: "Replacement URL is invalid"
invalid_tag_list: "Replacement tag list is invalid"
invalid_html: "HTML can only be used for replacement"
sidebar_section_link:
attributes:
linkable_type:

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddHtmlToWatchedWords < ActiveRecord::Migration[7.0]
def change
add_column :watched_words, :html, :boolean, default: false, null: false
end
end