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" "admin.watched_words.case_sensitive"
}}</span> }}</span>
{{/if}} {{/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; @service dialog;
@equal("actionKey", "replace") isReplace; @equal("actionKey", "replace") isReplace;
@equal("actionKey", "tag") isTag; @equal("actionKey", "tag") isTag;
@equal("actionKey", "link") isLink; @equal("actionKey", "link") isLink;
@alias("word.case_sensitive") isCaseSensitive; @alias("word.case_sensitive") isCaseSensitive;
@alias("word.html") isHtml;
@discourseComputed("word.replacement") @discourseComputed("word.replacement")
tags(replacement) { tags(replacement) {

View File

@ -75,6 +75,20 @@
</label> </label>
</div> </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 <DButton
@action={{this.submitForm}} @action={{this.submitForm}}
@disabled={{this.submitDisabled}} @disabled={{this.submitDisabled}}

View File

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

View File

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

View File

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

View File

@ -120,6 +120,6 @@ class Admin::WatchedWordsController < Admin::StaffController
def watched_words_params def watched_words_params
@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
end end

View File

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

View File

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

View File

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

View File

@ -4766,7 +4766,7 @@ en:
clear_filter: "Clear filter" clear_filter: "Clear filter"
no_results: no_results:
title: "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: welcome_topic_banner:
title: "Create your Welcome Topic" title: "Create your Welcome Topic"
@ -6102,6 +6102,7 @@ en:
one: "show %{count} word" one: "show %{count} word"
other: "show %{count} words" other: "show %{count} words"
case_sensitive: "(case-sensitive)" case_sensitive: "(case-sensitive)"
html: "(html)"
download: Download download: Download
clear_all: Clear All clear_all: Clear All
clear_all_confirm: "Are you sure you want to clear all watched words for the %{action} action?" 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." upload_successful: "Upload successful. Words have been added."
case_sensitivity_label: "Is case-sensitive" case_sensitivity_label: "Is case-sensitive"
case_sensitivity_description: "Only words with matching character casing" 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" words_or_phrases: "words or phrases"
test: test:
button_label: "Test" button_label: "Test"

View File

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