Created
June 17, 2011 04:22
-
-
Save LouCypher/1030865 to your computer and use it in GitHub Desktop.
Custom Buttons XML Exporter/Importer for Custom Buttons extension
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
Changelog: | |
* 2011-06-26: | |
1. Fixed all known issues. | |
2. No longer using toolbaritems, the cause of all issues. | |
* 2011-06-15: | |
1. Includes button's image as XML favicon | |
2. No longer using <script> | |
3. Fixed: namespace bug. | |
* 2011-06-14: | |
1. Includes template and CSS as you can see if you click the green button above. | |
I need your inputs in comment section about the template. | |
2. Yep! I changed the name again. | |
* 2011-06-12: Added 'Export to XML' to CB contextmenu. | |
* 2011-06-11: No more third button (dropmarker). | |
* 2011-06-10: | |
1. Fixed: toolbaritem (2-button) wasn't removed when this button was deleted. | |
Thanks to Morat. | |
2. Fixed: toolbaritem (2-button) wasn't removed when this button was moved. | |
3. Import XML file that is opened in browser window. Open this XML file to test it. | |
4. Supports application/xml content type as well as text/xml. | |
* 2011-06-09: | |
1. Initial release | |
2. Fixed: error with Cyrillic characters | |
3. Changed its name from Save/Load to Export/Import. | |
4. CB contextmenu now works with the two buttons. | |
5. Check for valid CB XML before installing an XML file to a new button. | |
Credits: | |
Contextmenu Icon by PixelMixer | |
- http://pixelmixer.ru/ | |
- http://pixel-mixer.com/ | |
References: | |
- https://addons.mozilla.org/firefox/files/browse/93414/file/content/loadsaveutils.js | |
- https://developer.mozilla.org/en/nsIFilePicker | |
- https://developer.mozilla.org/en/Code_snippets:File_I/O | |
- https://developer.mozilla.org/en/Parsing_and_serializing_XML | |
- https://developer.mozilla.org/en/Using_the_Stylesheet_Service | |
*/ | |
this.checkDocumentForCBXML(content.document); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* ***** BEGIN LICENSE BLOCK ***** | |
* Version: MPL 1.1/GPL 2.0/LGPL 2.1 | |
* | |
* The contents of this file are subject to the Mozilla Public License | |
* Version 1.1 (the "License"); you may not use this file except in | |
* compliance with the License. You may obtain a copy of the License at | |
* http://www.mozilla.org/MPL/ | |
* | |
* Software distributed under the License is distributed on an "AS IS" basis, | |
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License | |
* for the specific language governing rights and limitations under the | |
* License. | |
* | |
* Original code is Export Button to XML File/Import XML File As New Button | |
* for Custom Buttons extension | |
* | |
* The Initial Developer of the Original Code is LouCypher. | |
* Portions created by the Initial Developer are Copyright (C) 2011 | |
* the Initial Developer. All Rights Reserved. | |
* | |
* Contributor(s): | |
* - LouCypher: original code | |
* - Morat: onDestroy event, bug report | |
* | |
* Alternatively, the contents of this file may be used under the terms of | |
* either the GNU General Public License Version 2 or later (the "GPL"), | |
* in which case the provisions of the GPL are applicable instead of those | |
* above. | |
* | |
* ***** END LICENSE BLOCK ***** */ | |
const nsIFilePicker = Ci.nsIFilePicker; | |
const nsILocalFile = Ci.nsILocalFile; | |
function $(aId) { | |
return document.getElementById(aId); | |
} | |
var lastDirectory = { | |
_lastDir: null, | |
get path() { | |
if (!this._lastDir || !this._lastDir.exists()) { | |
try { | |
this._lastDir = cbu.ps.getComplexValue("custombuttons.XML.lastDir", | |
nsILocalFile); | |
if (!this._lastDir.exists()) | |
this._lastDir = null; | |
} | |
catch(e) {} | |
} | |
return this._lastDir; | |
}, | |
set path(val) { | |
if (!val || !val.exists() || !val.isDirectory()) | |
return; | |
this._lastDir = val.clone(); | |
cbu.ps.setComplexValue("custombuttons.XML.lastDir", | |
nsILocalFile, this._lastDir); | |
} | |
} | |
function saveFile(aFileName, aStrData) { | |
var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); | |
fp.appendFilters(nsIFilePicker.filterXML); | |
fp.init(window, "Export button to XML file", nsIFilePicker.modeSave); | |
fp.defaultString = aFileName; | |
fp.displayDirectory = lastDirectory.path; | |
var res = fp.show(); | |
if (res == nsIFilePicker.returnOK || res == nsIFilePicker.returnReplace) { | |
lastDirectory.path = fp.file.parent.QueryInterface(nsILocalFile); | |
var ostream = Cc["@mozilla.org/network/file-output-stream;1"]. | |
createInstance(Ci.nsIFileOutputStream); | |
ostream.init(fp.file, 0x02 | 0x08 | 0x20, 0664, 0); | |
var charset = "UTF-8"; | |
var os = Cc["@mozilla.org/intl/converter-output-stream;1"]. | |
createInstance(Ci.nsIConverterOutputStream); | |
os.init(ostream, charset, 4096, 0x0000); | |
os.writeString(aStrData); | |
os.close(); | |
} | |
} | |
function readFile(file) { | |
var data = ""; | |
var fstream = Cc["@mozilla.org/network/file-input-stream;1"]. | |
createInstance(Ci.nsIFileInputStream); | |
fstream.init(file, -1, 0, 0); | |
var charset = "UTF-8"; | |
const replacementChar = Ci.nsIConverterInputStream | |
.DEFAULT_REPLACEMENT_CHARACTER; | |
var is = Cc["@mozilla.org/intl/converter-input-stream;1"]. | |
createInstance(Ci.nsIConverterInputStream); | |
is.init(fstream, charset, 1024, replacementChar); | |
var str = {}; | |
while (is.readString(4096, str) != 0) { | |
data += str.value; | |
} | |
is.close(); | |
return data; | |
} | |
function stringToDOM(aString) { | |
// https://developer.mozilla.org/en/Parsing_and_serializing_XML | |
var parser = new DOMParser(); | |
var dom = parser.parseFromString(aString, "text/xml"); | |
if (dom.documentElement.nodeName == "parsererror") { | |
return null; | |
} else { | |
return dom.documentElement; | |
} | |
} | |
function importXMLtoButton(aStrXMLData) { | |
loadURI("custombutton://" + escape(aStrXMLData)); | |
} | |
this.checkDocumentForCBXML = function(aDocument) { | |
if (((aDocument.contentType == "text/xml") || | |
(aDocument.contentType == "application/xml"))&& | |
(aDocument.documentElement.localName == "custombutton")) { | |
var serializer = new XMLSerializer(); | |
var xml = serializer.serializeToString(aDocument); | |
importXMLtoButton(xml); | |
} else { | |
this.loadXML(); | |
} | |
} | |
this.saveXML = function(aStrURI) { | |
var cbURI = (aStrURI != undefined) ? aStrURI : readFromClipboard(); | |
if (!cbURI || !/^custombutton\:\/\//.test(cbURI)) { | |
custombuttons.uChelpButton(this); | |
return; | |
} | |
var cbXML = cbURI.replace(/^custombutton\:\/\//, ""); | |
var decodeXML = unescape(cbXML); | |
var btnName = decodeXML.match(/\<name\/?.+/).toString(); | |
var name = "untitled"; | |
if (!/\<name\/\>/.test(btnName)) { | |
name = btnName.replace(/\<\/?\w+\>/g, "").toString(); | |
} | |
var image = decodeXML.match(/\<image\/?.+/).toString(); | |
var icon = ""; | |
if (!/\<\image.*\[\].*\>$/.test(image)) { | |
icon = image.match(/[^\[\]]+/g)[2].toString() | |
.replace(/custombuttons\-stdicon\-\d/, "").toString(); | |
} | |
var xmlTemplate = "custombuttons/\"\n\ | |
xmlns:html=\"http://www.w3.org/1999/xhtml\">\n\ | |
<html:head>\n\ | |
<html:title><![CDATA[" + name + "]]></html:title>\n\ | |
<html:link rel=\"shortcut icon\" href=\"" + icon + "\"/>\n\ | |
<html:style type=\"text/css\"><![CDATA[\ | |
body { font-size: medium; margin: 0; }\n\ | |
body, code:before, help:before, initcode:before {\n\ | |
font-family: \"Verdana\", sans-serif;\n\ | |
} \n\ | |
#wrapper { position: fixed; top: 1em; right: 1em; text-align: center; }\n\ | |
p { font-size: small; text-align: center; }\n\ | |
#button {\n\ | |
background-image: -moz-linear-gradient(center top, rgb(147, 200, 94) 30%,\ | |
rgb(85, 168, 2) 55%);\n\ | |
border: 1px outset rgb(58, 116, 4);\n\ | |
border-radius: 1em;\n\ | |
-moz-border-radius: 1em;\n\ | |
padding: 0;\n\ | |
text-shadow: 0pt -1px 0pt rgb(58, 116, 4);\n\ | |
margin-bottom: 1em;\n\ | |
}\n\ | |
#button a {\n\ | |
color: rgb(255, 255, 255);\n\ | |
padding: 1em;\n\ | |
text-decoration: none;\n\ | |
}\n\ | |
#button a, code, code:before, initcode, initcode:before, help, help:before {\ | |
\n display: block;\n\ | |
}\n\ | |
#credits { position: fixed; bottom: 1em; right: 1em; font-size: small; }\n\ | |
custombutton { background-color: rgb(171, 171, 171); margin: 1em; }\n\ | |
image, mode, accelkey { display: none; }\n\ | |
name { font-weight: bold; font-size: x-large; }\n\ | |
code:before, help:before, initcode:before {\n\ | |
font-weight: bold;\n\ | |
font-size: large;\n\ | |
margin: 0 0 1em;\n\ | |
padding: .5em;\n\ | |
}\n\ | |
code:before { content: \"CODE\"; }\n\ | |
help:before { content: \"Help\"; }\n\ | |
initcode:before { content: \"Initialization Code\"; }\n\ | |
code, initcode, help {\n\ | |
background-color: rgb(255, 255, 255);\n\ | |
border: 1px inset rgb(170, 170, 170);\n\ | |
font: medium monospace;\n\ | |
margin: 1em 1em 2em 0;\n\ | |
padding: 1em;\n\ | |
text-align: left;\n\ | |
width: 840px;\n\ | |
white-space: pre-wrap;\n\ | |
word-wrap: break-word;\n\ | |
}\n\ | |
.clear { clear: both; }\n\ | |
]]></html:style>\n\ | |
</html:head>\n\ | |
<html:body>\n\ | |
<html:div id=\"wrapper\">\n\ | |
<html:div id=\"button\">\n\ | |
<html:a href=\"" + cbURI + "\" rel=\"nofollow\" title=\"" + | |
name +"\">\n\ | |
<![CDATA[Install this button]]>\n\ | |
</html:a>\n\ | |
</html:div>\n\ | |
<html:a href=\"https://addons.mozilla.org/addon/custom-buttons/\">\n\ | |
<![CDATA[What's this?]]>\n\ | |
</html:a>\n\ | |
<html:div id=\"credits\">\n\ | |
<html:a href=\"http://custombuttons.mozdev.org/drupal/node/484\">\n\ | |
<![CDATA[Custom Buttons XML]]><html:br/>\ | |
<![CDATA[Exporter/Importer]]>\n\ | |
</html:a>\n\ | |
</html:div>\n\ | |
</html:div>\n\ | |
</html:body>\n"; | |
decodeXML = decodeXML.replace(/custombuttons\/\"\>/, xmlTemplate); | |
name += ".xml"; | |
saveFile(name, decodeXML); | |
} | |
this.loadXML = function() { | |
var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); | |
fp.init(window, | |
"Import an XML file and install it as a new button", | |
nsIFilePicker.modeOpen); | |
fp.appendFilters(nsIFilePicker.filterXML); | |
fp.appendFilter("All Files", "*.*"); | |
fp.displayDirectory = lastDirectory.path; | |
if (fp.show() == nsIFilePicker.returnOK) { | |
if (fp.file && fp.file.exists()) { | |
lastDirectory.path = fp.file.parent.QueryInterface(nsILocalFile); | |
} | |
} else { | |
return; | |
} | |
var xmlData = readFile(fp.file); | |
var xmlDOM = stringToDOM(xmlData); | |
if (!xmlDOM) { | |
//Application.console.log(xmlDOM); | |
custombuttons.alertBox("Import Fail", "Not an XML file!"); | |
return; | |
} | |
if ((xmlDOM.localName == "custombutton") && | |
((xmlDOM.getAttribute("xmlns:cb") == "http://xsms.nm.ru/custombuttons/") || | |
(xmlDOM.getAttribute("xmlns:cb") == "http://xsms.nm.ru/custombuttons") || | |
(xmlDOM.getAttribute("xmlns") == "http://xsms.nm.ru/custombuttons/") || | |
(xmlDOM.getAttribute("xmlns") == "http://xsms.nm.ru/custombuttons"))) { | |
importXMLtoButton(xmlData); | |
} else { | |
custombuttons.alertBox("Import Fail", "Not a valid Custom Buttons XML!"); | |
} | |
} | |
//---------- Start initiating CB contextmenu ----------// | |
var saveImg = "data:image/x-icon;base64,\ | |
AAABAAEAEBAAAAAAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAQAQAAAAAAAAAAAAAAAAA\ | |
AAAAAAD///8B////Af///wH///8BAAAAHQAAACUXaE1dE55y/xOecv8XaE1dAAAAJQAAAB3///8B\ | |
////Af///wH///8B////Af///wH///8B////Af///wEmrX85F6J2/xHEj/8RxI//GKF2/yatfzn/\ | |
//8B////Af///wH///8B////Af///wH///8B////Af///wEmrX85Hqd6/xHHkv8Rx5L/EceS/xHH\ | |
kv8ep3r/Jq1/Of///wH///8B////Af///wH///8B////Af///wEhs4RJJq1//xHHkv8Rx5L/EceS\ | |
/xHHkv8Rx5L/EciT/yatgP8mrX85////Af///wH///8B////Af///wEjsYJBLLKE/xHNlv8RyJP/\ | |
EciT/xHIk/8RyJP/EciT/xHKlf8Rzpn/LLOE/yatfzn///8B////Af///wEmrX85MbaH/xTcqP8V\ | |
3qv/Fd2q/xHKlf8RypX/EcqV/xHKlf8W4a7/Fd6r/xTZpf8xtof/Jq1/Of///wEmrX1pMbaH/zG3\ | |
iP8xt4j/MbeI/xfWov8RzJj/EcyY/xHMmP8RzJj/H8SR/zG3iP8xt4j/MbeI/zG2h/8mrX85////\ | |
Af///wH///8B////Af///wEuuov/Ec+a/xHPmv8Rz5r/FNCc/xbUoPEisH7v////Af///wH///8B\ | |
////Af///wH///8B////Af///wH///8BLrqL/xHTnv8R057/EdOe/xXUoP8a2KT3H7J/4ymaaSUp\ | |
mmklKZppJSmaaSX///8B////Af///wH///8B////AS66i/8R1aH/EdWh/xHVof8U1qL/Idyp/ySh\ | |
b+0koW/tJKJw6ySlc+0noG/5////Af///wH///8B////Af///wEuuov/Edej/xHXo/8R16P/Edej\ | |
/yzgsP8ZsH//GrOB/xm6hv8ZwI3/JqFw6////wH///8B////Af///wH///8BLrqL/xHapf8R2qX/\ | |
Edql/xHapf895bf/KbCC/yCpef8gsH7/Hb6M/yidbNP///8B////Af///wH///8B////ASPNmv8Y\ | |
3ar/Edyn/xHcp/8V3aj/VerA/1DNpP8moHH/JqJz/yG7ifsvv5L/////Af///wH///8B////Af//\ | |
/wEizJn1b+/J/2nux/9k7cX/b+/J/3Dvyf9r7cb/MJ9x/yype/8jt4XzL7+S/////wH///8B////\ | |
Af///wH///8BFL+KOTjWp/9B6Lv/Oea4/zjmuP9G6Lz/WuzD/1rcs/9B0aT7L7+S/////wH///8B\ | |
////Af///wH///8B////Af///wEXwo5lFMWQzxPFkNUTxZDZE8WQ0xXEkNcmxJPVQsie/////wH/\ | |
//8BAAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA\ | |
//8AAP//AAD//w=="; | |
var cIDs = ["custombuttons-contextpopup-exportXML", | |
"custombuttons-contextpopup-exportXML-sub"]; | |
var bIDs = ["custombuttons-contextpopup-bookmarkButton", | |
"custombuttons-contextpopup-bookmarkButton-sub"]; | |
for (var i = 0; i < cIDs.length; i++) { | |
if ($(cIDs[i])) $(cIDs[i]).parentNode.removeChild($(cIDs[i])); | |
let item = cbu.makeXML(<menuitem xmlns={xulns} | |
id={cIDs[i]} | |
class="menuitem-iconic" | |
image={saveImg} | |
label="Export to XML" | |
oncommand={"document.getElementById('" + this.id + | |
"').saveXML(document.popupNode.URI);"}/>); | |
if (i == 0) { | |
item.setAttribute("observes", "custombuttons-contextbroadcaster-primary"); | |
} | |
$(bIDs[i]).parentNode.insertBefore(item, $(bIDs[i]).nextSibling); | |
} | |
// Remove contextmenu item when this button is deleted | |
this.onDestroy = function(aReason) { | |
if (aReason == "delete") { | |
for (var j = 0; j < cIDs.length; j++) { | |
$(cIDs[j]) && $(cIDs[j]).parentNode.removeChild($(cIDs[j])); | |
} | |
} | |
} | |
//---------- End initiating CB contextmenu ----------// | |
//---------- Remove old traces if any ----------// | |
let tbitem = $(this.id + "-toolbaritem"); | |
tbitem && tbitem.parentNode.removeChild(tbitem); | |
var css = "\ | |
@namespace url(http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul);\ | |
#navigator-toolbox:not([customizing=\"true\"]) #" + this.id + ",\ | |
#navigator-toolbox[customizing=\"true\"] #" + this.id + "-toolbaritem\ | |
{ display: none; }"; | |
var sss = Cc["@mozilla.org/content/style-sheet-service;1"]. | |
getService(Ci.nsIStyleSheetService); | |
var uri = makeURI("data:text/css," + encodeURIComponent(css), null, null); | |
if (sss.sheetRegistered(uri, sss.USER_SHEET)) { | |
sss.unregisterSheet(uri, sss.USER_SHEET); | |
} | |
////////////////////////////// Button updater ////////////////////////////// | |
this.updateURL = "http://loucypher.googlecode.com/svn/custombuttons/xml/" + | |
"Custom%20Buttons%20XML%20Exporter-Importer.xml"; | |
this.onclick = function(aEvent) { | |
if (!aEvent.shiftKey) return; | |
aEvent.preventDefault(); | |
this.updater.checkForUpdate(this.updater.getUpdate); | |
} | |
var btnClick = this.onclick; | |
var btnIcon = this.image; | |
var Button = this; | |
this.updater = { | |
get bsyIcon() { | |
return Application.name == "Firefox" | |
? Application.version >= "4" | |
? "chrome://browser/skin/tabbrowser/connecting.png" | |
: "chrome://global/skin/icons/loading_16.png" | |
: Application.name == "SeaMonkey" | |
? "chrome://communicator/skin/icons/loading.gif" | |
: "chrome://custombuttons/skin/button.png"; | |
}, | |
isValidCbURI: function isValidCbURI(aURL) { | |
if (!aURL) return false; | |
return /^custombutton\:\/\//.test(aURL); | |
}, | |
convertURItoDOM: function convertURItoDOM(aURL) { | |
if (!this.isValidCbURI(aURL)) { | |
custombuttons.alertBox(Button.name, "Not a Custom Buttons link!"); | |
return; | |
} | |
var string = unescape(aURL.replace(/^custombutton\:\/\//, "").toString()); | |
var parser = new DOMParser(); | |
var dom = parser.parseFromString(string, "text/xml"); | |
if (dom.documentElement.nodeName == "parsererror") { | |
return null; | |
} else { | |
return dom.documentElement; | |
} | |
}, | |
getParamValue: function getParamValue(aDocument, aNodeName) { | |
var node = aDocument.getElementsByTagName(aNodeName)[0]; | |
if (!node) return ""; | |
if (!node.firstChild || (node.firstChild && | |
(node.firstChild.nodeType == node.TEXT_NODE))) { | |
return node.textContent; | |
} else { | |
return node.firstChild.textContent; | |
} | |
}, | |
getButtonParameters: function getButtonParameters(aButtonLink, aURL) { | |
var dom = this.convertURItoDOM(aURL); | |
var params = custombuttons.cbService.getButtonParameters(aButtonLink) | |
.wrappedJSObject; | |
params.name = this.getParamValue(dom, "name") | |
params.image = this.getParamValue(dom, "image") || | |
this.getParamValue(dom, "stdicon"); | |
params.code = this.getParamValue(dom, "code") | |
params.initCode = this.getParamValue(dom, "initcode") | |
params.help = this.getParamValue(dom, "help") | |
params.accelkey = this.getParamValue(dom, "accelkey") | |
params.mode = this.getParamValue(dom, "mode") | |
params.wrappedJSObject = params; | |
return params; | |
}, | |
resetAttributes: function resetAttributes() { | |
Button.image = btnIcon; | |
Button.tooltipText = Button.name; | |
Button.removeAttribute("busy"); | |
Button.onclick = btnClick; | |
}, | |
checkForUpdate: function checkForUpdate(aCallback) { | |
var url = Button.updateURL + "?" + Date.now(); | |
var req = new XMLHttpRequest(); | |
req.open("GET", url, true); | |
if (Button.hasAttribute("busy")) { | |
this.resetAttributes(); | |
return | |
} | |
var updater = this; | |
req.onreadystatechange = function (aEvent) { | |
Button.onclick = function(aEvent) { | |
aEvent.preventDefault(); | |
req.abort(); | |
this.updater.resetAttributes(); | |
} | |
Button.image = updater.bsyIcon; | |
Button.setAttribute("busy", ""); | |
Button.tooltipText = "Checking for update...\nClick to abort."; | |
if (req.readyState == 4 && req.status == 200) { | |
updater.resetAttributes(); | |
aCallback(req.responseXML); | |
} | |
} | |
req.send(null); | |
}, | |
getUpdate: function getUpdate(aDocument) { | |
if (aDocument.documentElement.localName != "custombutton") { | |
alert("Not a valid Custom Buttons XML file!"); | |
return; | |
} | |
let button = aDocument.getElementById("button"); | |
let link = button.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", | |
"a")[0]; | |
if (link.href == Button.URI) { | |
let as = Cc['@mozilla.org/alerts-service;1']. | |
getService(Ci.nsIAlertsService); | |
as.showAlertNotification(btnIcon, "No update found!", | |
"Finish checking", false, "", null); | |
return; | |
} | |
var install = custombuttons.confirmBox(Button.name, "Update found! " + | |
"Update this button?", | |
"Yes", "No"); | |
if (!install) return; | |
let btnLink = custombuttons.makeButtonLink("update", Button.id); | |
let params = Button.updater.getButtonParameters(btnLink, link.href); | |
custombuttons.cbService.installButton(params); | |
custombuttons.alertBox(Button.name, "Button updated!"); | |
} | |
} | |
///////////////////////////// End Button updater //////////////////////////// | |
this.label = "Import XML File As New Button"; | |
this.tooltipText = this.Help; | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
To export: | |
1. Right click on any custom buttons | |
2. Select 'Export to XML' | |
To import: | |
1. Click this button | |
2. Select a Custom Buttons XML file | |
or just open any Custom Buttons XML file to import. | |
Shift+click: Find update for this button. | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment