Re-implement message HTML cleaning in JS in the web extension for WK2.
* ui/conversation-web-view.js: New script, port old HTML cleaning code in vala to Javascript as new subclass of PageState. Instantiate that on page load. * src/client/conversation-viewer/conversation-web-view.vala (ConversationWebView): Load and add new JS script for conversations. * src/client/web-process/util-conversation.vala (Util.Conversation): Remove migrated and obsolete code. * ui/client-web-view.js (PageState): Allow on-load behaviour to be overridden in subclasses. * ui/CMakeLists.txt: Include new JS script. * ui/conversation-web-view.css: Chase CSS class name changes.
This commit is contained in:
parent
7c5e5ae2cd
commit
431ebcb35f
7 changed files with 169 additions and 216 deletions
|
|
@ -210,7 +210,7 @@ public class GearyController : Geary.BaseObject {
|
|||
// Load web view resources
|
||||
try {
|
||||
ClientWebView.load_scripts(this.application);
|
||||
ConversationWebView.load_stylehseets(this.application);
|
||||
ConversationWebView.load_resources(this.application);
|
||||
} catch (Error err) {
|
||||
error("Error loading web resources: %s", err.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ public class ConversationWebView : ClientWebView {
|
|||
|
||||
private static WebKit.UserStyleSheet? user_stylesheet = null;
|
||||
private static WebKit.UserStyleSheet? app_stylesheet = null;
|
||||
private static WebKit.UserScript? app_script = null;
|
||||
|
||||
public static void load_stylehseets(GearyApplication app)
|
||||
public static void load_resources(GearyApplication app)
|
||||
throws Error {
|
||||
ConversationWebView.app_script =
|
||||
ClientWebView.load_app_script(app, "conversation-web-view.js");
|
||||
ConversationWebView.app_stylesheet =
|
||||
ClientWebView.load_app_stylesheet(app, "conversation-web-view.css");
|
||||
ConversationWebView.user_stylesheet =
|
||||
|
|
@ -23,12 +26,12 @@ public class ConversationWebView : ClientWebView {
|
|||
|
||||
|
||||
public ConversationWebView() {
|
||||
WebKit.UserContentManager manager = new WebKit.UserContentManager();
|
||||
manager.add_style_sheet(ConversationWebView.app_stylesheet);
|
||||
base();
|
||||
this.user_content_manager.add_script(ConversationWebView.app_script);
|
||||
this.user_content_manager.add_style_sheet(ConversationWebView.app_stylesheet);
|
||||
if (ConversationWebView.user_stylesheet != null) {
|
||||
manager.add_style_sheet(ConversationWebView.user_stylesheet);
|
||||
this.user_content_manager.add_style_sheet(ConversationWebView.user_stylesheet);
|
||||
}
|
||||
base(manager);
|
||||
}
|
||||
|
||||
public void clean_and_load(string html) {
|
||||
|
|
|
|||
|
|
@ -8,143 +8,12 @@
|
|||
|
||||
namespace Util.Conversation {
|
||||
|
||||
private const string SIGNATURE_CONTAINER_CLASS = "geary_signature";
|
||||
|
||||
private const string QUOTE_CONTAINER_CLASS = "geary_quote_container";
|
||||
private const string QUOTE_CONTROLLABLE_CLASS = "controllable";
|
||||
private const string QUOTE_HIDE_CLASS = "hide";
|
||||
private const float QUOTE_SIZE_THRESHOLD = 2.0f;
|
||||
|
||||
|
||||
public double get_preferred_height(WebKit.WebPage page) {
|
||||
WebKit.DOM.Element html = page.get_dom_document().get_document_element();
|
||||
double offset_height = html.offset_height;
|
||||
double offset_width = html.offset_width;
|
||||
double px = offset_width * offset_height;
|
||||
|
||||
const double MAX_LEN = 15.0 * 1000;
|
||||
const double MAX_PX = 10.0 * 1000 * 1000;
|
||||
|
||||
// If the offset_width is very small, the offset_height will
|
||||
// likely be bogus, so just pretend we have no height for the
|
||||
// moment. WebKitGTK seems to report an offset width of 1 in
|
||||
// these cases.
|
||||
if (offset_width > 1) {
|
||||
if (offset_height > MAX_LEN || px > MAX_PX) {
|
||||
double new_height = double.min(MAX_LEN, MAX_PX / offset_width);
|
||||
debug("Clamping window height to: %f, current size: %fx%f (%fpx)",
|
||||
new_height, offset_width, offset_height, px);
|
||||
offset_height = new_height;
|
||||
}
|
||||
} else {
|
||||
offset_height = 0;
|
||||
}
|
||||
|
||||
return offset_height;
|
||||
}
|
||||
|
||||
public string clean_html_markup(WebKit.WebPage page, string text, Geary.RFC822.Message message) {
|
||||
try {
|
||||
WebKit.DOM.HTMLElement html = (WebKit.DOM.HTMLElement)
|
||||
page.get_dom_document().document_element;
|
||||
|
||||
// If the message has a HTML element, get its inner
|
||||
// markup. We can't just set this on a temp container div
|
||||
// (the old approach) using set_inner_html() will refuse
|
||||
// to parse any HTML, HEAD and BODY elements that are out
|
||||
// of place in the structure. We can't use
|
||||
// set_outer_html() on the document element since it
|
||||
// throws an error.
|
||||
GLib.Regex html_regex = new GLib.Regex("<html([^>]*)>(.*)</html>",
|
||||
GLib.RegexCompileFlags.DOTALL);
|
||||
GLib.MatchInfo matches;
|
||||
if (html_regex.match(text, 0, out matches)) {
|
||||
// Set the existing HTML element's content. Here, HEAD
|
||||
// and BODY elements will be parsed fine.
|
||||
html.set_inner_html(matches.fetch(2));
|
||||
// Copy email HTML element attrs across to the
|
||||
// existing HTML element
|
||||
string attrs = matches.fetch(1);
|
||||
if (attrs != "") {
|
||||
WebKit.DOM.HTMLElement container = create(page, "div");
|
||||
container.set_inner_html(@"<div$attrs></div>");
|
||||
WebKit.DOM.HTMLElement? attr_element =
|
||||
Util.DOM.select(container, "div");
|
||||
WebKit.DOM.NamedNodeMap html_attrs =
|
||||
attr_element.get_attributes();
|
||||
for (int i = 0; i < html_attrs.get_length(); i++) {
|
||||
WebKit.DOM.Node attr = html_attrs.item(i);
|
||||
html.set_attribute(attr.node_name, attr.text_content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html.set_inner_html(text);
|
||||
}
|
||||
|
||||
// Set dir="auto" if not already set possibly get a
|
||||
// slightly better RTL experience.
|
||||
string? dir = html.get_dir();
|
||||
if (dir == null || dir.length == 0) {
|
||||
html.set_dir("auto");
|
||||
}
|
||||
|
||||
// Get all the top level block quotes and stick them into a hide/show controller.
|
||||
WebKit.DOM.NodeList blockquote_list = html.query_selector_all("blockquote");
|
||||
for (int i = 0; i < blockquote_list.length; ++i) {
|
||||
// Get the nodes we need.
|
||||
WebKit.DOM.Node blockquote_node = blockquote_list.item(i);
|
||||
WebKit.DOM.Node? next_sibling = blockquote_node.get_next_sibling();
|
||||
WebKit.DOM.Node parent = blockquote_node.get_parent_node();
|
||||
|
||||
// Make sure this is a top level blockquote.
|
||||
if (Util.DOM.node_is_child_of(blockquote_node, "BLOCKQUOTE")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
WebKit.DOM.Element quote_container = create_quote_container(page);
|
||||
Util.DOM.select(quote_container, ".quote").append_child(blockquote_node);
|
||||
if (next_sibling == null) {
|
||||
parent.append_child(quote_container);
|
||||
} else {
|
||||
parent.insert_before(quote_container, next_sibling);
|
||||
}
|
||||
}
|
||||
|
||||
// Now look for the signature.
|
||||
wrap_html_signature(page, ref html);
|
||||
|
||||
// Now return the whole message.
|
||||
return html.get_outer_html();
|
||||
} catch (Error e) {
|
||||
debug("Error modifying HTML message: %s", e.message);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
public void unset_controllable_quotes(WebKit.WebPage page)
|
||||
throws Error {
|
||||
WebKit.DOM.HTMLElement html =
|
||||
page.get_dom_document().document_element as WebKit.DOM.HTMLElement;
|
||||
if (html != null) {
|
||||
WebKit.DOM.NodeList quote_list = html.query_selector_all(
|
||||
".%s.%s".printf(QUOTE_CONTAINER_CLASS, QUOTE_CONTROLLABLE_CLASS)
|
||||
);
|
||||
for (int i = 0; i < quote_list.length; ++i) {
|
||||
WebKit.DOM.Element quote_container = quote_list.item(i) as WebKit.DOM.Element;
|
||||
double outer_client_height = quote_container.client_height;
|
||||
long scroll_height = quote_container.query_selector(".quote").scroll_height;
|
||||
// If the message is hidden, scroll_height will be
|
||||
// 0. Otherwise, unhide the full quote if there is not a
|
||||
// substantial amount hidden.
|
||||
if (scroll_height > 0 &&
|
||||
scroll_height <= outer_client_height * QUOTE_SIZE_THRESHOLD) {
|
||||
//quote_container.class_list.remove(QUOTE_CONTROLLABLE_CLASS);
|
||||
//quote_container.class_list.remove(QUOTE_HIDE_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? get_selection_for_quoting(WebKit.WebPage page) {
|
||||
string? quote = null;
|
||||
// WebKit.DOM.Document document = page.get_dom_document();
|
||||
|
|
@ -206,59 +75,4 @@ namespace Util.Conversation {
|
|||
return value;
|
||||
}
|
||||
|
||||
private WebKit.DOM.HTMLElement create(WebKit.WebPage page, string name)
|
||||
throws Error {
|
||||
return page.get_dom_document().create_element(name) as WebKit.DOM.HTMLElement;
|
||||
}
|
||||
|
||||
private WebKit.DOM.HTMLElement create_quote_container(WebKit.WebPage page) throws Error {
|
||||
WebKit.DOM.HTMLElement quote_container = create(page, "div");
|
||||
// quote_container.class_list.add(QUOTE_CONTAINER_CLASS);
|
||||
// quote_container.class_list.add(QUOTE_CONTROLLABLE_CLASS);
|
||||
// quote_container.class_list.add(QUOTE_HIDE_CLASS);
|
||||
// New lines are preserved within blockquotes, so this string
|
||||
// needs to be new-line free.
|
||||
quote_container.set_inner_html("""<div class="shower"><input type="button" value="▼ ▼ ▼" /></div><div class="hider"><input type="button" value="▲ ▲ ▲" /></div><div class="quote"></div>""");
|
||||
return quote_container;
|
||||
}
|
||||
|
||||
private void wrap_html_signature(WebKit.WebPage page, ref WebKit.DOM.HTMLElement container) throws Error {
|
||||
// Most HTML signatures fall into one of these designs which are handled by this method:
|
||||
//
|
||||
// 1. GMail: <div>-- </div>$SIGNATURE
|
||||
// 2. GMail Alternate: <div><span>-- </span></div>$SIGNATURE
|
||||
// 3. Thunderbird: <div>-- <br>$SIGNATURE</div>
|
||||
//
|
||||
WebKit.DOM.NodeList div_list = container.query_selector_all("div,span,p");
|
||||
int i = 0;
|
||||
Regex sig_regex = new Regex("^--\\s*$");
|
||||
Regex alternate_sig_regex = new Regex("^--\\s*(?:<br|\\R)");
|
||||
for (; i < div_list.length; ++i) {
|
||||
// Get the div and check that it starts a signature block and is not inside a quote.
|
||||
WebKit.DOM.HTMLElement div = div_list.item(i) as WebKit.DOM.HTMLElement;
|
||||
string inner_html = div.get_inner_html();
|
||||
if ((sig_regex.match(inner_html) || alternate_sig_regex.match(inner_html)) &&
|
||||
!Util.DOM.node_is_child_of(div, "BLOCKQUOTE")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a signature, move it and all of its following siblings that are not quotes
|
||||
// inside a signature div.
|
||||
if (i == div_list.length) {
|
||||
return;
|
||||
}
|
||||
WebKit.DOM.Node elem = div_list.item(i) as WebKit.DOM.Node;
|
||||
WebKit.DOM.Element parent = elem.get_parent_element();
|
||||
WebKit.DOM.HTMLElement signature_container = create(page, "div");
|
||||
//signature_container.class_list.add(SIGNATURE_CONTAINER_CLASS);
|
||||
do {
|
||||
// Get its sibling _before_ we move it into the signature div.
|
||||
WebKit.DOM.Node? sibling = elem.get_next_sibling();
|
||||
signature_container.append_child(elem);
|
||||
elem = sibling;
|
||||
} while (elem != null);
|
||||
parent.append_child(signature_container);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ set(RESOURCE_LIST
|
|||
STRIPBLANKS "conversation-message-menus.ui"
|
||||
STRIPBLANKS "conversation-viewer.ui"
|
||||
"conversation-web-view.css"
|
||||
"conversation-web-view.js"
|
||||
STRIPBLANKS "edit_alternate_emails.glade"
|
||||
STRIPBLANKS "empty-placeholder.ui"
|
||||
STRIPBLANKS "find_bar.glade"
|
||||
|
|
|
|||
|
|
@ -15,16 +15,19 @@ var PageState = function() {
|
|||
PageState.prototype = {
|
||||
init: function() {
|
||||
this.allowRemoteImages = false;
|
||||
this.loaded = false;
|
||||
this.is_loaded = false;
|
||||
|
||||
var state = this;
|
||||
var timeoutId = window.setInterval(function() {
|
||||
state.preferredHeightChanged();
|
||||
if (state.loaded) {
|
||||
if (state.is_loaded) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}, 50);
|
||||
},
|
||||
loaded: function() {
|
||||
this.is_loaded = true;
|
||||
},
|
||||
loadRemoteImages: function() {
|
||||
this.allowRemoteImages = true;
|
||||
var images = document.getElementsByTagName("IMG");
|
||||
|
|
@ -51,8 +54,3 @@ PageState.prototype = {
|
|||
window.webkit.messageHandlers.selectionChanged.postMessage(has_selection);
|
||||
}
|
||||
};
|
||||
|
||||
var geary = new PageState();
|
||||
window.onload = function() {
|
||||
geary.loaded = true;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -70,13 +70,13 @@ pre {
|
|||
* Message chrome style.
|
||||
*/
|
||||
|
||||
.geary_signature {
|
||||
.geary-signature {
|
||||
color: #777;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.geary_signature a,
|
||||
.geary_quote_container a {
|
||||
.geary-signature a,
|
||||
.geary-quote-container a {
|
||||
color: #5fb2e7;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ pre {
|
|||
|
||||
/* Inline collapsable quote blocks */
|
||||
|
||||
.geary_quote_container {
|
||||
.geary-quote-container {
|
||||
position: relative;
|
||||
/* Split 1em of top/bottom margin between here and the default
|
||||
blockquote style, so if a message specifies 0px margin and padding
|
||||
|
|
@ -102,11 +102,11 @@ pre {
|
|||
color: #303030;
|
||||
background-color: #e8e8e8;/* recv-quoted */
|
||||
}
|
||||
.geary_sent .geary_quote_container {
|
||||
.geary-sent .geary-quote-container {
|
||||
background-color: #e8e8e8;/* sent-quoted */
|
||||
}
|
||||
|
||||
.geary_quote_container > .quote {
|
||||
.geary-quote-container > .geary-quote {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
|
@ -114,18 +114,18 @@ pre {
|
|||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
.geary_quote_container.controllable.hide > .quote {
|
||||
.geary-quote-container.geary-controllable.geary-hide > .geary-quote {
|
||||
/* Use a fraction value to cut the last visible line off half way. */
|
||||
max-height: 7.75em;
|
||||
}
|
||||
|
||||
.geary_quote_container.controllable > .quote > blockquote {
|
||||
.geary-quote-container.geary-controllable > .geary-quote > blockquote {
|
||||
/* Add space between the quote and the hider button */
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.geary_quote_container > .shower,
|
||||
.geary_quote_container > .hider {
|
||||
.geary-quote-container > .geary-shower,
|
||||
.geary-quote-container > .geary-hider {
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 0;
|
||||
|
|
@ -136,24 +136,25 @@ pre {
|
|||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.geary_quote_container > .shower > input,
|
||||
.geary_quote_container > .hider > input {
|
||||
.geary-quote-container > .geary-shower > input,
|
||||
.geary-quote-container > .geary-hider > input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
font-size: 8px; /* Absolute size in pixels for graphics */
|
||||
color: #888;
|
||||
}
|
||||
.geary_quote_container > .shower:hover > input,
|
||||
.geary_quote_container > .hider:hover > input {
|
||||
.geary-quote-container > .geary-shower:hover > input,
|
||||
.geary-quote-container > .geary-hider:hover > input {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.geary_quote_container.controllable.hide > .hider {
|
||||
.geary-quote-container.geary-controllable.geary-hide > .geary-hider {
|
||||
display: none;
|
||||
}
|
||||
.geary_quote_container.controllable.hide > .shower,
|
||||
.geary_quote_container.controllable > .hider {
|
||||
.geary-quote-container.geary-controllable.geary-hide > .geary-shower,
|
||||
.geary-quote-container.geary-controllable > .geary-hider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
|
|||
136
ui/conversation-web-view.js
Normal file
136
ui/conversation-web-view.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Copyright 2016 Michael Gratton <mike@vee.net>
|
||||
*
|
||||
* This software is licensed under the GNU Lesser General Public License
|
||||
* (version 2.1 or later). See the COPYING file in this distribution.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Application logic for ConversationWebView.
|
||||
*/
|
||||
var ConversationPageState = function() {
|
||||
this.init.apply(this, arguments);
|
||||
};
|
||||
ConversationPageState.prototype = {
|
||||
__proto__: PageState.prototype,
|
||||
init: function() {
|
||||
PageState.prototype.init.apply(this, []);
|
||||
},
|
||||
loaded: function() {
|
||||
this.updateDirection();
|
||||
this.createControllableQuotes();
|
||||
this.wrapSignature();
|
||||
// Call after so we continue to a preferred size update after
|
||||
// munging the HTML above.
|
||||
PageState.prototype.loaded.apply(this, []);
|
||||
},
|
||||
/**
|
||||
* Set dir="auto" if not already set.
|
||||
*
|
||||
* This should provide a slightly better RTL experience.
|
||||
*/
|
||||
updateDirection: function() {
|
||||
var dir = document.documentElement.dir;
|
||||
if (dir == null || dir.trim() == "") {
|
||||
document.documentElement.dir = "auto";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Add top level blockquotes to hide/show container.
|
||||
*/
|
||||
createControllableQuotes: function() {
|
||||
var blockquoteList = document.documentElement.querySelectorAll("blockquote");
|
||||
for (var i = 0; i < blockquoteList.length; ++i) {
|
||||
var blockquote = blockquoteList.item(i);
|
||||
var nextSibling = blockquote.nextSibling;
|
||||
var parent = blockquote.parentNode;
|
||||
|
||||
// Only insert into a quote container if the element is a
|
||||
// top level blockquote
|
||||
if (!ConversationPageState.isDescendantOf(blockquote, "BLOCKQUOTE")) {
|
||||
var quoteContainer = document.createElement("DIV");
|
||||
quoteContainer.classList.add("geary-quote-container");
|
||||
|
||||
// Only make it controllable if the quote is tall enough
|
||||
if (blockquote.offsetHeight > 50) {
|
||||
quoteContainer.classList.add("geary-controllable");
|
||||
quoteContainer.classList.add("geary-hide");
|
||||
}
|
||||
// New lines are preserved within blockquotes, so this
|
||||
// string needs to be new-line free.
|
||||
quoteContainer.innerHTML =
|
||||
"<div class=\"geary-shower\">" +
|
||||
"<input type=\"button\" value=\"▼ ▼ ▼\" />" +
|
||||
"</div>" +
|
||||
"<div class=\"geary-hider\">" +
|
||||
"<input type=\"button\" value=\"▲ ▲ ▲\" />" +
|
||||
"</div>";
|
||||
|
||||
var quoteDiv = document.createElement("DIV");
|
||||
quoteDiv.classList.add("geary-quote");
|
||||
quoteDiv.appendChild(blockquote);
|
||||
|
||||
quoteContainer.appendChild(quoteDiv);
|
||||
parent.insertBefore(quoteContainer, nextSibling);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Look for and wrap a signature.
|
||||
*
|
||||
* Most HTML signatures fall into one
|
||||
* of these designs which are handled by this method:
|
||||
*
|
||||
* 1. GMail: <div>-- </div>$SIGNATURE
|
||||
* 2. GMail Alternate: <div><span>-- </span></div>$SIGNATURE
|
||||
* 3. Thunderbird: <div>-- <br>$SIGNATURE</div>
|
||||
*
|
||||
*/
|
||||
wrapSignature: function() {
|
||||
var possibleSigs = document.documentElement.querySelectorAll("div,span,p");
|
||||
var i = 0;
|
||||
var sigRegex = new RegExp("^--\\s*$");
|
||||
var alternateSigRegex = new RegExp("^--\\s*(?:<br|\\R)");
|
||||
for (; i < possibleSigs.length; ++i) {
|
||||
// Get the div and check that it starts a signature block
|
||||
// and is not inside a quote.
|
||||
var div = possibleSigs.item(i);
|
||||
var innerHTML = div.innerHTML;
|
||||
if ((sigRegex.test(innerHTML) || alternateSigRegex.test(innerHTML)) &&
|
||||
!ConversationPageState.isDescendantOf(div, "BLOCKQUOTE")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If we have a signature, move it and all of its following
|
||||
// siblings that are not quotes inside a signature div.
|
||||
if (i < possibleSigs.length) {
|
||||
var elem = possibleSigs.item(i);
|
||||
var parent = elem.parentNode;
|
||||
var signatureContainer = document.createElement("DIV");
|
||||
signatureContainer.classList.add("geary-signature");
|
||||
do {
|
||||
// Get its sibling _before_ we move it into the signature div.
|
||||
var sibling = elem.nextSibling;
|
||||
signatureContainer.appendChild(elem);
|
||||
elem = sibling;
|
||||
} while (elem != null);
|
||||
parent.appendChild(signatureContainer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ConversationPageState.isDescendantOf = function(node, ancestorTag) {
|
||||
var ancestor = node.parentNode;
|
||||
while (ancestor != null) {
|
||||
if (ancestor.tagName == ancestorTag) {
|
||||
return true;
|
||||
}
|
||||
ancestor = ancestor.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
var geary = new ConversationPageState();
|
||||
window.onload = function() {
|
||||
geary.loaded();
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue