Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bbcode support in rss description #2428

Merged
merged 2 commits into from
Jan 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
@import "./css/style.css";
</style>
<script type="text/javascript" src="./js/jquery.js"></script>
<script type="text/javascript" src="./js/sanitize.js"></script>
<script type="text/javascript" src="./js/sanitize.config.js"></script>
<script type="text/javascript" src="./lang/langs.js"></script>
<script type="text/javascript" src="./js/common.js"></script>
<script type="text/javascript" src="./js/objects.js"></script>
Expand All @@ -36,8 +38,6 @@
<script type="text/javascript" src="./js/plugins.js"></script>
<script type="text/javascript" src="./js/rtorrent.js"></script>
<script type="text/javascript" src="./js/webui.js"></script>
<script type="text/javascript" src="./js/sanitize.js"></script>
<script type="text/javascript" src="./js/sanitize.config.js"></script>
</head>
<body>
<div id="preload"></div>
Expand Down
322 changes: 315 additions & 7 deletions plugins/rss/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -1147,14 +1147,322 @@ rTorrentStub.prototype.setrsssettings = function()
this.rssCommon("mode=setsettings&interval="+this.ss[0]+"&delayerrui="+this.ss[1]);
}

rTorrentStub.prototype.getrssdetailsResponse = function(data)
{
const doc = new DOMParser().parseFromString(String(data), 'text/html');
var s = new Sanitize(Sanitize.Config.RESTRICTED);
$("#rsslayout").html(s.clean_node(doc.body));
return(false);
}
theWebUI.mapBBCodeToHTML = function (htmlText) {
const tags = {
...Object.fromEntries(
[ "b", "i", "sup", "sub", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "li" ].map((t) => [t, () => [t]])
),
...Object.fromEntries(
["ul", "ol", "list"].map((name) => [
name,
(_, content) => {
const htmlTag = name === "list" ? "ul" : name;
const ele = $(`<${htmlTag}>`).html(content);
const list = $(`<${htmlTag}>`);
let lastLiNode = $("<li>");
for (const node of ele.contents()) {
if (node.nodeName.toLowerCase() === "li") {
// keep li nodes
lastLiNode = $(node);
} else {
if (node.nodeType === 3) {
// parse list items denoted by [*] and *
const items = String(node.nodeValue)
.replaceAll(/(^|[\s\]])\*\s/g, "[*]")
.split(/\[\*\]/g);
if (!list.children("li").length) {
// set text of empty list
list.text(items.shift());
}
const firstItem = items.shift();
if (firstItem) {
// add textnode to lastLiNode
lastLiNode.append(document.createTextNode(firstItem));
}
for (const item of items) {
list.append(lastLiNode);
lastLiNode = $("<li>").text(item);
}
} else {
// add some node to lastLiNode
lastLiNode.append($(node));
}
}
// add lastLiNode to list (if not added already)
list.append(lastLiNode);
}
return [htmlTag, {}, list[0].innerHTML];
},
])
),
u: () => ["ins"],
s: () => ["del"],
...Object.fromEntries(
["small", "normal", "large"].map((t) => [
t,
() => ["span", { class: `bbcode-size-${t}` }],
])
),
size: (arg) => ["span", { class: `bbcode-size-${arg}` }],
color: (arg) => ["span", { class: `bbcode-color-${arg}` }],
...Object.fromEntries(
["center", "left", "right"].map((t) => [
t,
() => ["span", { class: `bbcode-align-${t}` }],
])
),
...Object.fromEntries(
["font", "face"].map((t) => [
t,
(arg) => ["span", { class: (arg || "").toLowerCase() }],
])
),
style: (_, __, args) => [
"span",
{
class: Object.entries(args)
.map(([k, v]) => `bbcode-${k}-${v}`)
.join(" "),
},
],
img: (arg, content, args) => [
"img",
{
src: content,
...Object.fromEntries(
(arg || "")
.split("x")
.map((v, k) => [["width", "height"][k], Number.parseInt(v)])
.filter(([_, v]) => !Number.isNaN(v))
),
...args,
},
"",
],
url: (arg, content) => ["a", { href: arg == null ? content : arg }],
email: (arg, content) => [
"a",
{ href: `mailto:${arg == null ? content : arg}` },
],
quote: (arg, content, args) => [
"blockquote",
{},
$("<p>").html(content)[0].outerHTML +
$("<span>")
.addClass("bbcode-quote")
.text("-- ")
.append($("<cite>").text(arg || args["author"] || ""))[0].outerHTML,
],
code: () => ["pre", { class: "bbcode-code" }],
spoiler: (arg, content) => [
"details",
{},
$("<summary>").html(arg)[0].outerHTML + content,
],
"bbcode-root": () => ["div"],
};

const trimArg = (arg) =>
arg == null
? null
: arg.startsWith('"')
? arg.substring(1, arg.length - 1)
: arg.trim();
const argsToDict = (args) => {
const dict = {};
for (const match of args.matchAll(
/\s+?(?<name>[a-z]+)=(?<arg>"(.*?)"|[^\s]*)/gi
)) {
const { name, arg } = match.groups;
if (name && arg) {
dict[name] = trimArg(arg);
}
}
return dict;
};

const nodeToElement = (node) => {
const htmlContent = node.children
.map((n) => (n.name ? nodeToElement(n).outerHTML : n))
.join("");
const arg = trimArg(node.arg);
const args = node.args ? argsToDict(node.args) : {};
const [htmlTag, attribs, htmlContentProcessed] = tags[node.name](
arg,
htmlContent,
args
);
const ele = $(`<${htmlTag}>`)
.attr(attribs || {})
.html(htmlContentProcessed || htmlContent)[0];
return ele;
};

const simpleParamPattern = '\\s*?=\\s*?(?<arg>"(.*?)"|.*?)';
const complexParamPattern = '(?<args>(\\s+?[a-z]+=("(.*?)"|[^\\s]*?))+)';
const tagPattern = new RegExp(
"\\[\\/?(?<name>" +
Object.keys(tags).join("|") +
")(" +
simpleParamPattern +
"|" +
complexParamPattern +
")?\\s*?\\]",
"gsi"
);
let nodeStack = [{ name: "bbcode-root", children: [] }];
let offset = 0;
for (const match of htmlText.matchAll(tagPattern)) {
const parent = nodeStack[nodeStack.length - 1];
const { name, arg, args } = match.groups;
const closing = match[0].startsWith("[/");
// add textnode to parent
const textnode = match.input.substring(offset, match.index);
if (textnode) {
parent.children.push(textnode);
}
if (closing) {
if (parent.name === name && nodeStack.length > 1) {
nodeStack.pop();
} else {
// encoutered unexpected close tag
nodeStack = [nodeStack[0]];
}
} else {
const node = { name, arg, args, children: [] };
parent.children.push(node);
// make curnode to parent node
nodeStack.push(node);
}
offset = match.index + match[0].length;
}
nodeStack[nodeStack.length-1].children.push(htmlText.substring(offset, htmlText.length));
const htmlContent = nodeToElement(nodeStack[0]).innerHTML;

// Support for some emoticons from WhatCD/Gazelle (https://github.com/WhatCD/Gazelle/tree/master/static/common/smileys)
// :code: => utf8 emoticon (https://utf8-icons.com/subset/emoticons)
const emoticons = {
smile: "&#128578;",
blank: "&#128528;",
biggrin: "&#128513;",
angry: "&#128545;",
blush: "&#128522;",
cool: "&#128526;",
crying: "&#128546;",
frown: "&#128577;",
unsure: "&#128533;",
lol: "&#128516;",
ninja: "&#129399;",
no: "&#128581;",
ohno: "&#128552;",
ohnoes: "&#128552;",
omg: "&#128576;",
shifty: "&#128530;",
sick: "&#128567;",
wink: "&#128521;",
creepy: "&#128520;",
tongue: "&#128540;",
thumbsup: "&#128077;",
"+1": "&#128077;",
thumbsdown: "&#128078;",
"-1": "&#128078;",
};
const emoticonRegExp = new RegExp(
":(" +
Object.keys(emoticons)
.map((e) => e.replaceAll(/\+/g, "\\+"))
.join("|") +
"):",
"g"
);
return htmlContent.replace(
emoticonRegExp,
(_, iconName) => emoticons[iconName]
);
};

rTorrentStub.prototype.getrssdetailsResponse = function (data) {
const colorNamePattern =
/^(?:aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen)$/;
const colorCodePattern = /^#?[a-f0-9]{6}$/i;
const restirctedFontSize = (value) => {
const size = Math.max(4, Math.min(40, Number.parseInt(value)));
return Number.isNaN(size) ? "normal" : `${size}px`;
};
const bbNodeAttr = {
color: (value) =>
colorNamePattern.test(value)
? { color: value }
: colorCodePattern.test(value)
? { color: value.startsWith("#") ? value : `#${value}` }
: null,
size: (value) =>
["small", "large", "normal"].includes(value)
? "class"
: { "font-size": restirctedFontSize(value) },
align: (value) =>
["left", "right", "center"].includes(value) ? "class" : null,
font: (value) =>
[ "times", "courier", "arial", "serif", "sans", "fantasy", "monospace", "caps", ].includes(value) ? "class" : null,
};
const bbclassTransform = (cfg) => {
const node = cfg.node;
if (!["pre", "span"].includes(node.nodeName.toLowerCase())) {
return null;
}
let styles = {};
let classes = [];
for (const bbClass of (node.attributes.class?.value || "").split(" ")) {
const [bbcode, key, value] = bbClass.split("-");
if (bbcode === "bbcode") {
const style = key in bbNodeAttr ? bbNodeAttr[key](value || "") : null;
if (style !== null) {
if (style !== "class") {
styles = { ...styles, ...style };
}
classes.push(
`${bbcode}-${key}` + (style === "class" ? `-${value}` : "")
);
}
}
}
// replace existing attributes with style and class
[...node.attributes].forEach((attr) => node.removeAttribute(attr.name));
for (const [name, value] of [
["style", Object.entries(styles).map((e) => e.join(": ")).join("; ")],
["class", classes.join(" ")],
]) {
if (value) {
const attr = cfg.dom.createAttribute(name);
attr.value = value;
node.attributes[name] = attr;
}
}
return {
whitelist: Boolean(classes.length),
attr_whitelist: ["class", "style"],
node,
};
};
const cfg = Sanitize.Config.RESTRICTED;
const s = new Sanitize({
elements: [...cfg.elements, "ins", "details", "summary"],
transformers: [bbclassTransform],
});
const rawHTML = String(data);
const dirtyHTML = theWebUI.mapBBCodeToHTML(rawHTML);
const doc = new DOMParser().parseFromString(dirtyHTML, "text/html");
$("#rsslayout")
.empty()
.append(
$("<details>")
.addClass('raw-details')
.text(rawHTML)
.append($("<hr>"))
.append($("<summary>").text("Raw")),
$('<div>').html(s.clean_node(doc.body)));
return false;
};

rTorrentStub.prototype.setfilters = function()
{
Expand Down
20 changes: 20 additions & 0 deletions plugins/rss/rss.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ div#FLT_buttons {clear: both}
#FLTBtn {width: 30px}

#rsslayout { border: 0; display: none; margin: 5px; white-space: pre-line; }
#rsslayout div { max-width: 700px; }
.bbcode-code { padding: 1em; border: 1px solid; }
.bbcode-quote { display: block; padding-left: 1.6em; }
.bbcode-size, .bbcode-size-normal { font-size: normal; }
.bbcode-size-small { font-size: smaller; }
.bbcode-size-large { font-size: larger; }
.bbcode-color { color: inherit; }
.bbcode-align-left { display: block; text-align: start; }
.bbcode-align-right { display: block; text-align: end; }
.bbcode-align-center { display: block; margin-left: auto; margin-right: auto; text-align: center; }
.bbcode-font-times { font-family: 'Times New Roman', Times, serif; }
.bbcode-font-courier { font-family: 'Courier New', Courier, monospace; }
.bbcode-font-arial { font-family: Arial, Helvetica, sans-serif; }
.bbcode-font-serif { font-family: serif; }
.bbcode-font-sans { font-family: sans-serif; }
.bbcode-font-fantasy { font-family: fantasy; }
.bbcode-font-monospace { font-family: monospace, monospace; }
.bbcode-font-caps { font-variant: small-caps; }
#rsslayout .raw-details { font-size: smaller; float: right; padding: 0 0 0.5em 0.5em; max-width: 700px; }
#rsslayout .raw-details summary { opacity: 0.5; text-align: right;}

#dlgAddRSSGroup {width: 320px; height: 300px}
#dlgAddRSSGroup div.dlg-header {background-image: url(./images/rss.gif)}
Expand Down
Loading