Compare commits

...

8 Commits

Author SHA1 Message Date
dgtlmoon
a345d66577 Small tidyup 2024-10-08 14:45:01 +02:00
dgtlmoon
30d5a12e9a Make better exmaple 2024-10-08 14:44:44 +02:00
dgtlmoon
2ba061c767 UI - Fix incorrect placeholder text example 2024-10-08 14:43:35 +02:00
dgtlmoon
7b7d23d975 Small fixes 2024-10-08 12:41:59 +02:00
dgtlmoon
49cf58dc4f Add debug 2024-10-08 12:05:27 +02:00
dgtlmoon
ae20990e91 misc tweak 2024-10-08 12:02:32 +02:00
dgtlmoon
38e9b81922 Use fancy scroller for showing that AJAX is active 2024-10-08 11:37:07 +02:00
dgtlmoon
51cb83a20a Refactor preview code to its own module 2024-10-08 10:52:01 +02:00
11 changed files with 286 additions and 211 deletions

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import datetime import datetime
import importlib
import flask_login import flask_login
import locale import locale
@@ -12,9 +11,7 @@ import threading
import time import time
import timeago import timeago
from .content_fetchers.exceptions import ReplyWithContentButNoText
from .processors import find_processors, get_parent_module, get_custom_watch_obj_for_processor from .processors import find_processors, get_parent_module, get_custom_watch_obj_for_processor
from .processors.text_json_diff.processor import FilterNotFoundInResponse
from .safe_jinja import render as jinja_render from .safe_jinja import render as jinja_render
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from copy import deepcopy from copy import deepcopy
@@ -1381,79 +1378,9 @@ def changedetection_app(config=None, datastore_o=None):
@app.route("/edit/<string:uuid>/preview-rendered", methods=['POST']) @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])
@login_optionally_required @login_optionally_required
def watch_get_preview_rendered(uuid): def watch_get_preview_rendered(uuid):
from flask import jsonify
'''For when viewing the "preview" of the rendered text from inside of Edit''' '''For when viewing the "preview" of the rendered text from inside of Edit'''
now = time.time() from .processors.text_json_diff import prepare_filter_prevew
import brotli return prepare_filter_prevew(watch_uuid=uuid, datastore=datastore)
from . import forms
text_after_filter = ''
tmp_watch = deepcopy(datastore.data['watching'].get(uuid))
if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
# Splice in the temporary stuff from the form
form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None,
data=request.form
)
# Only update vars that came in via the AJAX post
p = {k: v for k, v in form.data.items() if k in request.form.keys()}
tmp_watch.update(p)
latest_filename = next(reversed(tmp_watch.history))
html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br")
with open(html_fname, 'rb') as f:
decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')
# Just like a normal change detection except provide a fake "watch" object and dont call .call_browser()
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid # probably not needed anymore anyway?
)
# Use the last loaded HTML as the input
update_handler.fetcher.content = decompressed_data
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
try:
changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(
watch=tmp_watch,
skip_when_checksum_same=False,
)
except FilterNotFoundInResponse as e:
text_after_filter = f"Filter not found in HTML: {str(e)}"
except ReplyWithContentButNoText as e:
text_after_filter = f"Filter found but no text (empty result)"
except Exception as e:
text_after_filter = f"Error: {str(e)}"
if not text_after_filter.strip():
text_after_filter = 'Empty content'
# because run_changedetection always returns bytes due to saving the snapshots etc
text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter
do_anchor = datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
trigger_line_numbers = []
try:
text_before_filter = html_tools.html_to_text(html_content=decompressed_data,
render_anchor_tag_content=do_anchor)
trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=tmp_watch['trigger_text'],
mode='line numbers'
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
logger.trace(f"Parsed in {time.time() - now:.3f}s")
return jsonify(
{
'after_filter': text_after_filter,
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
'duration': time.time() - now,
'trigger_line_numbers': trigger_line_numbers,
}
)
@app.route("/form/add/quickwatch", methods=['POST']) @app.route("/form/add/quickwatch", methods=['POST'])

View File

@@ -36,8 +36,9 @@ class model(watch_base):
jitter_seconds = 0 jitter_seconds = 0
def __init__(self, *arg, **kw): def __init__(self, *arg, **kw):
self.__datastore_path = kw['datastore_path'] self.__datastore_path = kw.get('datastore_path')
del kw['datastore_path'] if kw.get('datastore_path'):
del kw['datastore_path']
super(model, self).__init__(*arg, **kw) super(model, self).__init__(*arg, **kw)
if kw.get('default'): if kw.get('default'):
self.update(kw['default']) self.update(kw['default'])
@@ -171,6 +172,10 @@ class model(watch_base):
""" """
tmp_history = {} tmp_history = {}
# In the case we are only using the watch for processing without history
if not self.watch_data_dir:
return []
# Read the history file as a dict # Read the history file as a dict
fname = os.path.join(self.watch_data_dir, "history.txt") fname = os.path.join(self.watch_data_dir, "history.txt")
if os.path.isfile(fname): if os.path.isfile(fname):
@@ -396,8 +401,8 @@ class model(watch_base):
@property @property
def watch_data_dir(self): def watch_data_dir(self):
# The base dir of the watch data # The base dir of the watch data
return os.path.join(self.__datastore_path, self['uuid']) return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
def get_error_text(self): def get_error_text(self):
"""Return the text saved from a previous request that resulted in a non-200 error""" """Return the text saved from a previous request that resulted in a non-200 error"""
fname = os.path.join(self.watch_data_dir, "last-error.txt") fname = os.path.join(self.watch_data_dir, "last-error.txt")

View File

@@ -0,0 +1,107 @@
from loguru import logger
def _task(watch, update_handler):
from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
text_after_filter = ''
try:
# The slow process (we run 2 of these in parallel)
changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(
watch=watch,
skip_when_checksum_same=False,
)
except FilterNotFoundInResponse as e:
text_after_filter = f"Filter not found in HTML: {str(e)}"
except ReplyWithContentButNoText as e:
text_after_filter = f"Filter found but no text (empty result)"
except Exception as e:
text_after_filter = f"Error: {str(e)}"
if not text_after_filter.strip():
text_after_filter = 'Empty content'
# because run_changedetection always returns bytes due to saving the snapshots etc
text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter
return text_after_filter
def prepare_filter_prevew(datastore, watch_uuid):
'''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
from changedetectionio import forms, html_tools
from changedetectionio.model.Watch import model as watch_model
from concurrent.futures import ProcessPoolExecutor
from copy import deepcopy
from flask import request, jsonify
import brotli
import importlib
import os
import time
now = time.time()
text_after_filter = ''
text_before_filter = ''
tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))
if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
# Splice in the temporary stuff from the form
form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None,
data=request.form
)
# Only update vars that came in via the AJAX post
p = {k: v for k, v in form.data.items() if k in request.form.keys()}
tmp_watch.update(p)
blank_watch_no_filters = watch_model()
blank_watch_no_filters['url'] = tmp_watch.get('url')
latest_filename = next(reversed(tmp_watch.history))
html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br")
with open(html_fname, 'rb') as f:
decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')
# Just like a normal change detection except provide a fake "watch" object and dont call .call_browser()
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=tmp_watch.get('uuid') # probably not needed anymore anyway?
)
# Use the last loaded HTML as the input
update_handler.datastore = datastore
update_handler.fetcher.content = decompressed_data
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
# Do this as a parallel process because it could take some time
with ProcessPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(_task, tmp_watch, update_handler)
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
text_after_filter = future1.result()
text_before_filter = future2.result()
trigger_line_numbers = []
try:
trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=tmp_watch['trigger_text'],
mode='line numbers'
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
logger.trace(f"Parsed in {time.time() - now:.3f}s")
return jsonify(
{
'after_filter': text_after_filter,
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
'duration': time.time() - now,
'trigger_line_numbers': trigger_line_numbers,
}
)

View File

@@ -1,56 +0,0 @@
/**
* debounce
* @param {integer} milliseconds This param indicates the number of milliseconds
* to wait after the last call before calling the original function.
* @param {object} What "this" refers to in the returned function.
* @return {function} This returns a function that when called will wait the
* indicated number of milliseconds after the last call before
* calling the original function.
*/
Function.prototype.debounce = function (milliseconds, context) {
var baseFunction = this,
timer = null,
wait = milliseconds;
return function () {
var self = context || this,
args = arguments;
function complete() {
baseFunction.apply(self, args);
timer = null;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(complete, wait);
};
};
/**
* throttle
* @param {integer} milliseconds This param indicates the number of milliseconds
* to wait between calls before calling the original function.
* @param {object} What "this" refers to in the returned function.
* @return {function} This returns a function that when called will wait the
* indicated number of milliseconds between calls before
* calling the original function.
*/
Function.prototype.throttle = function (milliseconds, context) {
var baseFunction = this,
lastEventTimestamp = null,
limit = milliseconds;
return function () {
var self = context || this,
args = arguments,
now = Date.now();
if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
lastEventTimestamp = now;
baseFunction.apply(self, args);
}
};
};

View File

@@ -1,64 +1,106 @@
(function($) { (function ($) {
/**
* debounce
* @param {integer} milliseconds This param indicates the number of milliseconds
* to wait after the last call before calling the original function.
* @param {object} What "this" refers to in the returned function.
* @return {function} This returns a function that when called will wait the
* indicated number of milliseconds after the last call before
* calling the original function.
*/
Function.prototype.debounce = function (milliseconds, context) {
var baseFunction = this,
timer = null,
wait = milliseconds;
/* return function () {
$('#code-block').highlightLines([ var self = context || this,
{ args = arguments;
'color': '#dd0000',
'lines': [10, 12]
},
{
'color': '#ee0000',
'lines': [15, 18]
}
]);
});
*/
$.fn.highlightLines = function(configurations) { function complete() {
return this.each(function() { baseFunction.apply(self, args);
const $pre = $(this); timer = null;
const textContent = $pre.text(); }
const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings
// Build a map of line numbers to styles if (timer) {
const lineStyles = {}; clearTimeout(timer);
}
configurations.forEach(config => { timer = setTimeout(complete, wait);
const { color, lines: lineNumbers } = config; };
lineNumbers.forEach(lineNumber => { };
lineStyles[lineNumber] = color;
/**
* throttle
* @param {integer} milliseconds This param indicates the number of milliseconds
* to wait between calls before calling the original function.
* @param {object} What "this" refers to in the returned function.
* @return {function} This returns a function that when called will wait the
* indicated number of milliseconds between calls before
* calling the original function.
*/
Function.prototype.throttle = function (milliseconds, context) {
var baseFunction = this,
lastEventTimestamp = null,
limit = milliseconds;
return function () {
var self = context || this,
args = arguments,
now = Date.now();
if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
lastEventTimestamp = now;
baseFunction.apply(self, args);
}
};
};
$.fn.highlightLines = function (configurations) {
return this.each(function () {
const $pre = $(this);
const textContent = $pre.text();
const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings
// Build a map of line numbers to styles
const lineStyles = {};
configurations.forEach(config => {
const {color, lines: lineNumbers} = config;
lineNumbers.forEach(lineNumber => {
lineStyles[lineNumber] = color;
});
});
// Function to escape HTML characters
function escapeHtml(text) {
return text.replace(/[&<>"'`=\/]/g, function (s) {
return "&#" + s.charCodeAt(0) + ";";
});
}
// Process each line
const processedLines = lines.map((line, index) => {
const lineNumber = index + 1; // Line numbers start at 1
const escapedLine = escapeHtml(line);
const color = lineStyles[lineNumber];
if (color) {
// Wrap the line in a span with inline style
return `<span style="background-color: ${color}">${escapedLine}</span>`;
} else {
return escapedLine;
}
});
// Join the lines back together
const newContent = processedLines.join('\n');
// Set the new content as HTML
$pre.html(newContent);
}); });
}); };
$.fn.miniTabs = function (tabsConfig, options) {
// Function to escape HTML characters
function escapeHtml(text) {
return text.replace(/[&<>"'`=\/]/g, function(s) {
return "&#" + s.charCodeAt(0) + ";";
});
}
// Process each line
const processedLines = lines.map((line, index) => {
const lineNumber = index + 1; // Line numbers start at 1
const escapedLine = escapeHtml(line);
const color = lineStyles[lineNumber];
if (color) {
// Wrap the line in a span with inline style
return `<span style="background-color: ${color}">${escapedLine}</span>`;
} else {
return escapedLine;
}
});
// Join the lines back together
const newContent = processedLines.join('\n');
// Set the new content as HTML
$pre.html(newContent);
});
};
$.fn.miniTabs = function(tabsConfig, options) {
const settings = { const settings = {
tabClass: 'minitab', tabClass: 'minitab',
tabsContainerClass: 'minitabs', tabsContainerClass: 'minitabs',
@@ -66,10 +108,10 @@
...(options || {}) ...(options || {})
}; };
return this.each(function() { return this.each(function () {
const $wrapper = $(this); const $wrapper = $(this);
const $contents = $wrapper.find('div[id]').hide(); const $contents = $wrapper.find('div[id]').hide();
const $tabsContainer = $('<div>', { class: settings.tabsContainerClass }).prependTo($wrapper); const $tabsContainer = $('<div>', {class: settings.tabsContainerClass}).prependTo($wrapper);
// Generate tabs // Generate tabs
Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => { Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => {
@@ -84,7 +126,7 @@
}); });
// Tab click event // Tab click event
$tabsContainer.on('click', `.${settings.tabClass}`, function(e) { $tabsContainer.on('click', `.${settings.tabClass}`, function (e) {
e.preventDefault(); e.preventDefault();
const $tab = $(this); const $tab = $(this);
const target = $tab.data('target'); const target = $tab.data('target');
@@ -103,7 +145,7 @@
// Object to store ongoing requests by namespace // Object to store ongoing requests by namespace
const requests = {}; const requests = {};
$.abortiveSingularAjax = function(options) { $.abortiveSingularAjax = function (options) {
const namespace = options.namespace || 'default'; const namespace = options.namespace || 'default';
// Abort the current request in this namespace if it's still ongoing // Abort the current request in this namespace if it's still ongoing

View File

@@ -49,4 +49,9 @@ $(document).ready(function () {
$("#overlay").toggleClass('visible'); $("#overlay").toggleClass('visible');
heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)'; heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
}); });
setInterval(function () {
$('body').toggleClass('spinner-active', $.active > 0);
}, 2000);
}); });

View File

@@ -25,14 +25,16 @@ function request_textpreview_update() {
const name = $element.attr('name'); // Get the name attribute of the element const name = $element.attr('name'); // Get the name attribute of the element
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val(); data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
}); });
$('#text-preview-spinner').show();
$('body').toggleClass('spinner-active', 1);
$.abortiveSingularAjax({ $.abortiveSingularAjax({
type: "POST", type: "POST",
url: preview_text_edit_filters_url, url: preview_text_edit_filters_url,
data: data, data: data,
namespace: 'watchEdit' namespace: 'watchEdit'
}).done(function (data) { }).done(function (data) {
$('#text-preview-spinner').fadeOut(); console.debug(data['duration'])
$('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']); $('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);
$('#filters-and-triggers #text-preview-inner') $('#filters-and-triggers #text-preview-inner')
.text(data['after_filter']) .text(data['after_filter'])
@@ -43,7 +45,6 @@ function request_textpreview_update() {
} }
]); ]);
}).fail(function (error) { }).fail(function (error) {
$('#text-preview-spinner').fadeOut();
if (error.statusText === 'abort') { if (error.statusText === 'abort') {
console.log('Request was aborted due to a new request being fired.'); console.log('Request was aborted due to a new request being fired.');
} else { } else {
@@ -71,18 +72,13 @@ $(document).ready(function () {
$("#text-preview-inner").css('max-height', (vh-300)+"px"); $("#text-preview-inner").css('max-height', (vh-300)+"px");
$("#text-preview-before-inner").css('max-height', (vh-300)+"px"); $("#text-preview-before-inner").css('max-height', (vh-300)+"px");
// Realtime preview of 'Filters & Text' setup
var debounced_request_textpreview_update = request_textpreview_update.debounce(100);
$("#activate-text-preview").click(function (e) { $("#activate-text-preview").click(function (e) {
$('body').toggleClass('preview-text-enabled') $('body').toggleClass('preview-text-enabled')
request_textpreview_update(); request_textpreview_update();
const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off'; const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off';
$("#text-preview-refresh")[method]('click', debounced_request_textpreview_update); $('textarea:visible')[method]('keyup blur', request_textpreview_update.throttle(1000));
$('textarea:visible')[method]('keyup blur', debounced_request_textpreview_update); $('input:visible')[method]('keyup blur change', request_textpreview_update.throttle(1000));
$('input:visible')[method]('keyup blur change', debounced_request_textpreview_update); $("#filters-and-triggers-tab")[method]('click', request_textpreview_update.throttle(1000));
$("#filters-and-triggers-tab")[method]('click', debounced_request_textpreview_update);
}); });
$('.minitabs-wrapper').miniTabs({ $('.minitabs-wrapper').miniTabs({
"Content after filters": "#text-preview-inner", "Content after filters": "#text-preview-inner",

View File

@@ -106,10 +106,34 @@ button.toggle-button {
padding: 5px; padding: 5px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
border-bottom: 2px solid var(--color-menu-accent);
align-items: center; align-items: center;
} }
#pure-menu-horizontal-spinner {
height: 3px;
background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
background-size: 400% 400%;
width: 100%;
animation: gradient 200s ease infinite;
}
body.spinner-active {
#pure-menu-horizontal-spinner {
animation: gradient 1s ease infinite;
}
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.pure-menu-heading { .pure-menu-heading {
color: var(--color-text-menu-heading); color: var(--color-text-menu-heading);
} }

View File

@@ -573,9 +573,26 @@ button.toggle-button {
padding: 5px; padding: 5px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
border-bottom: 2px solid var(--color-menu-accent);
align-items: center; } align-items: center; }
#pure-menu-horizontal-spinner {
height: 3px;
background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
background-size: 400% 400%;
width: 100%;
animation: gradient 200s ease infinite; }
body.spinner-active #pure-menu-horizontal-spinner {
animation: gradient 1s ease infinite; }
@keyframes gradient {
0% {
background-position: 0% 50%; }
50% {
background-position: 100% 50%; }
100% {
background-position: 0% 50%; } }
.pure-menu-heading { .pure-menu-heading {
color: var(--color-text-menu-heading); } color: var(--color-text-menu-heading); }

View File

@@ -35,7 +35,9 @@
<body class=""> <body class="">
<div class="header"> <div class="header">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu"> <div class="pure-menu-fixed" style="width: 100%;">
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
{% if has_password and not current_user.is_authenticated %} {% if has_password and not current_user.is_authenticated %}
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener"> <a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
<strong>Change</strong>Detection.io</a> <strong>Change</strong>Detection.io</a>
@@ -129,7 +131,12 @@
</li> </li>
</ul> </ul>
</div> </div>
<div id="pure-menu-horizontal-spinner"></div>
</div>
</div> </div>
{% if hosted_sticky %} {% if hosted_sticky %}
<div class="sticky-tab" id="hosted-sticky"> <div class="sticky-tab" id="hosted-sticky">
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a> <a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>

View File

@@ -398,7 +398,9 @@ Unavailable") }}
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }} {{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
or
keyword") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match; <li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
@@ -434,7 +436,6 @@ Unavailable") }}
</div> </div>
</div> </div>
</div> </div>
<div class="spinner" style="display: none;" id="text-preview-spinner"></div>
</div> </div>
</div> </div>
</div> </div>