imported cloud storage support from uBlock: user rules supported for now

pull/2/head
gorhill 9 years ago
parent 9e4e4943f3
commit f3876463d3

@ -865,6 +865,183 @@ vAPI.cookies.remove = function(details, callback) {
/******************************************************************************/ /******************************************************************************/
/******************************************************************************/ /******************************************************************************/
vAPI.cloud = (function() {
var chunkCountPerFetch = 16; // Must be a power of 2
// Mind chrome.storage.sync.MAX_ITEMS (512 at time of writing)
var maxChunkCountPerItem = Math.floor(512 * 0.75) & ~(chunkCountPerFetch - 1);
// Mind chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 at time of writing)
var maxChunkSize = Math.floor(chrome.storage.sync.QUOTA_BYTES_PER_ITEM * 0.75);
// Mind chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 at time of writing)
var maxStorageSize = chrome.storage.sync.QUOTA_BYTES;
var options = {
defaultDeviceName: window.navigator.platform,
deviceName: window.localStorage.getItem('deviceName') || ''
};
// This is used to find out a rough count of how many chunks exists:
// We "poll" at specific index in order to get a rough idea of how
// large is the stored string.
// This allows reading a single item with only 2 sync operations -- a
// good thing given chrome.storage.syncMAX_WRITE_OPERATIONS_PER_MINUTE
// and chrome.storage.syncMAX_WRITE_OPERATIONS_PER_HOUR.
var getCoarseChunkCount = function(dataKey, callback) {
var bin = {};
for ( var i = 0; i < maxChunkCountPerItem; i += 16 ) {
bin[dataKey + i.toString()] = '';
}
chrome.storage.sync.get(bin, function(bin) {
if ( chrome.runtime.lastError ) {
callback(0, chrome.runtime.lastError.message);
return;
}
var chunkCount = 0;
for ( var i = 0; i < maxChunkCountPerItem; i += 16 ) {
if ( bin[dataKey + i.toString()] === '' ) {
break;
}
chunkCount = i + 16;
}
callback(chunkCount);
});
};
var deleteChunks = function(dataKey, start) {
var keys = [];
// No point in deleting more than:
// - The max number of chunks per item
// - The max number of chunks per storage limit
var n = Math.min(
maxChunkCountPerItem,
Math.ceil(maxStorageSize / maxChunkSize)
);
for ( var i = start; i < n; i++ ) {
keys.push(dataKey + i.toString());
}
chrome.storage.sync.remove(keys);
};
var start = function(/* dataKeys */) {
};
var push = function(dataKey, data, callback) {
var bin = {
'source': options.deviceName || options.defaultDeviceName,
'tstamp': Date.now(),
'data': data,
'size': 0
};
bin.size = JSON.stringify(bin).length;
var item = JSON.stringify(bin);
// Chunkify taking into account QUOTA_BYTES_PER_ITEM:
// https://developer.chrome.com/extensions/storage#property-sync
// "The maximum size (in bytes) of each individual item in sync
// "storage, as measured by the JSON stringification of its value
// "plus its key length."
bin = {};
var chunkCount = Math.ceil(item.length / maxChunkSize);
for ( var i = 0; i < chunkCount; i++ ) {
bin[dataKey + i.toString()] = item.substr(i * maxChunkSize, maxChunkSize);
}
bin[dataKey + i.toString()] = ''; // Sentinel
chrome.storage.sync.set(bin, function() {
var errorStr;
if ( chrome.runtime.lastError ) {
errorStr = chrome.runtime.lastError.message;
}
callback(errorStr);
// Remove potentially unused trailing chunks
deleteChunks(dataKey, chunkCount);
});
};
var pull = function(dataKey, callback) {
var assembleChunks = function(bin) {
if ( chrome.runtime.lastError ) {
callback(null, chrome.runtime.lastError.message);
return;
}
// Assemble chunks into a single string.
var json = [], jsonSlice;
var i = 0;
for (;;) {
jsonSlice = bin[dataKey + i.toString()];
if ( jsonSlice === '' ) {
break;
}
json.push(jsonSlice);
i += 1;
}
var entry = null;
try {
entry = JSON.parse(json.join(''));
} catch(ex) {
}
callback(entry);
};
var fetchChunks = function(coarseCount, errorStr) {
if ( coarseCount === 0 || typeof errorStr === 'string' ) {
callback(null, errorStr);
return;
}
var bin = {};
for ( var i = 0; i < coarseCount; i++ ) {
bin[dataKey + i.toString()] = '';
}
chrome.storage.sync.get(bin, assembleChunks);
};
getCoarseChunkCount(dataKey, fetchChunks);
};
var getOptions = function(callback) {
if ( typeof callback !== 'function' ) {
return;
}
callback(options);
};
var setOptions = function(details, callback) {
if ( typeof details !== 'object' || details === null ) {
return;
}
if ( typeof details.deviceName === 'string' ) {
window.localStorage.setItem('deviceName', details.deviceName);
options.deviceName = details.deviceName;
}
getOptions(callback);
};
return {
start: start,
push: push,
pull: pull,
getOptions: getOptions,
setOptions: setOptions
};
})();
/******************************************************************************/
/******************************************************************************/
})(); })();
/******************************************************************************/ /******************************************************************************/

@ -2851,6 +2851,125 @@ vAPI.punycodeURL = function(url) {
/******************************************************************************/ /******************************************************************************/
/******************************************************************************/ /******************************************************************************/
vAPI.cloud = (function() {
var extensionBranchPath = 'extensions.' + location.host;
var cloudBranchPath = extensionBranchPath + '.cloudStorage';
// https://github.com/gorhill/uBlock/issues/80#issuecomment-132081658
// We must use get/setComplexValue in order to properly handle strings
// with unicode characters.
var iss = Ci.nsISupportsString;
var argstr = Components.classes['@mozilla.org/supports-string;1']
.createInstance(iss);
var options = {
defaultDeviceName: '',
deviceName: ''
};
// User-supplied device name.
try {
options.deviceName = Services.prefs
.getBranch(extensionBranchPath + '.')
.getComplexValue('deviceName', iss)
.data;
} catch(ex) {
}
var getDefaultDeviceName = function() {
var name = '';
try {
name = Services.prefs
.getBranch('services.sync.client.')
.getComplexValue('name', iss)
.data;
} catch(ex) {
}
return name || window.navigator.platform || window.navigator.oscpu;
};
var start = function(dataKeys) {
var extensionBranch = Services.prefs.getBranch(extensionBranchPath + '.');
var syncBranch = Services.prefs.getBranch('services.sync.prefs.sync.');
// Mark config entries as syncable
argstr.data = '';
var dataKey;
for ( var i = 0; i < dataKeys.length; i++ ) {
dataKey = dataKeys[i];
if ( extensionBranch.prefHasUserValue('cloudStorage.' + dataKey) === false ) {
extensionBranch.setComplexValue('cloudStorage.' + dataKey, iss, argstr);
}
syncBranch.setBoolPref(cloudBranchPath + '.' + dataKey, true);
}
};
var push = function(datakey, data, callback) {
var branch = Services.prefs.getBranch(cloudBranchPath + '.');
var bin = {
'source': options.deviceName || getDefaultDeviceName(),
'tstamp': Date.now(),
'data': data,
'size': 0
};
bin.size = JSON.stringify(bin).length;
argstr.data = JSON.stringify(bin);
branch.setComplexValue(datakey, iss, argstr);
if ( typeof callback === 'function' ) {
callback();
}
};
var pull = function(datakey, callback) {
var result = null;
var branch = Services.prefs.getBranch(cloudBranchPath + '.');
try {
var json = branch.getComplexValue(datakey, iss).data;
if ( typeof json === 'string' ) {
result = JSON.parse(json);
}
} catch(ex) {
}
callback(result);
};
var getOptions = function(callback) {
if ( typeof callback !== 'function' ) {
return;
}
options.defaultDeviceName = getDefaultDeviceName();
callback(options);
};
var setOptions = function(details, callback) {
if ( typeof details !== 'object' || details === null ) {
return;
}
var branch = Services.prefs.getBranch(extensionBranchPath + '.');
if ( typeof details.deviceName === 'string' ) {
argstr.data = details.deviceName;
branch.setComplexValue('deviceName', iss, argstr);
options.deviceName = details.deviceName;
}
getOptions(callback);
};
return {
start: start,
push: push,
pull: pull,
getOptions: getOptions,
setOptions: setOptions
};
})();
/******************************************************************************/
/******************************************************************************/
vAPI.browserData = {}; vAPI.browserData = {};
/******************************************************************************/ /******************************************************************************/

@ -314,7 +314,10 @@
"message": "Collapse placeholder of blocked elements", "message": "Collapse placeholder of blocked elements",
"description": "English: Collapse placeholder of blocked elements" "description": "English: Collapse placeholder of blocked elements"
}, },
"settingsCloudStorageEnabled" : {
"message": "Enable cloud storage support",
"description": ""
},
"privacyPageTitle" : { "privacyPageTitle" : {
"message": "uMatrix &ndash; Privacy", "message": "uMatrix &ndash; Privacy",
@ -680,6 +683,31 @@
"description":"Appears in Firefox's add-on preferences" "description":"Appears in Firefox's add-on preferences"
}, },
"cloudPush": {
"message": "Export to cloud storage",
"description": "tooltip"
},
"cloudPull": {
"message": "Import from cloud storage",
"description": "tooltip"
},
"cloudNoData": {
"message": "...\n...",
"description": ""
},
"cloudDeviceNamePrompt": {
"message": "This device name:",
"description": "used as a prompt for the user to provide a custom device name"
},
"genericSubmit": {
"message": "Submit",
"description": "for generic 'submit' buttons"
},
"genericRevert": {
"message": "Revert",
"description": "for generic 'revert' buttons"
},
"errorCantConnectTo":{ "errorCantConnectTo":{
"message":"Network error: Unable to connect to {{url}}", "message":"Network error: Unable to connect to {{url}}",
"description":"" "description":""

@ -0,0 +1,92 @@
#cloudWidget {
background: url("../img/cloud.png") hsl(216, 100%, 93%);
border-radius: 3px;
margin: 0.5em 0;
padding: 1em;
position: relative;
}
#cloudWidget.hide {
display: none;
}
#cloudWidget > button {
font-size: 160%;
padding: 0.1em 0.2em;
}
#cloudPull[disabled] {
visibility: hidden;
}
#cloudPush:after ,
#cloudPull:before {
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
line-height: 1;
vertical-align: baseline;
display: inline-block;
}
body[dir="ltr"] #cloudPush:after {
content: '\f0ee';
}
body[dir="rtl"] #cloudPush:after {
content: '\f0ee';
}
body[dir="ltr"] #cloudPull:before {
content: '\f0ed';
}
body[dir="rtl"] #cloudPull:before {
content: '\f0ed';
}
#cloudWidget > span {
color: gray;
display: inline-block;
font-size: 90%;
margin: 0 1em;
padding: 0;
vertical-align: bottom;
white-space: pre;
}
#cloudWidget > .nodata {
}
#cloudWidget > #cloudCog {
cursor: pointer;
display: inline-block;
font-size: 110%;
margin: 0;
opacity: 0.5;
padding: 4px;
position: absolute;
top: 0;
}
body[dir="ltr"] #cloudWidget > #cloudCog {
right: 0;
}
body[dir="rtl"] #cloudWidget > #cloudCog {
left: 0;
}
#cloudWidget > #cloudCog:hover {
opacity: 1;
}
#cloudWidget > #cloudOptions {
align-items: center;
-webkit-align-items: center;
background-color: rgba(0, 0, 0, 0.75);
bottom: 0;
display: none;
justify-content: center;
-webkit-justify-content: center;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 2000;
}
#cloudWidget > #cloudOptions.show {
display: flex;
display: -webkit-flex;
}
#cloudWidget > #cloudOptions > div {
background-color: white;
border-radius: 3px;
padding: 1em;
text-align: center;
}

@ -17,6 +17,7 @@ div > p:last-child {
margin: 0; margin: 0;
padding: 0; padding: 0;
position: relative; position: relative;
vertical-align: top;
white-space: normal; white-space: normal;
width: calc(50% - 2px); width: calc(50% - 2px);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

@ -104,6 +104,7 @@ return {
autoUpdate: false, autoUpdate: false,
clearBrowserCache: true, clearBrowserCache: true,
clearBrowserCacheAfter: 60, clearBrowserCacheAfter: 60,
cloudStorageEnabled: false,
collapseBlocked: false, collapseBlocked: false,
colorBlindFriendly: false, colorBlindFriendly: false,
deleteCookies: false, deleteCookies: false,

@ -0,0 +1,201 @@
/*******************************************************************************
uBlock Origin - a browser extension to block requests.
Copyright (C) 2015 Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
/* global uDom */
'use strict';
/******************************************************************************/
(function() {
/******************************************************************************/
self.cloud = {
options: {},
datakey: '',
data: undefined,
onPush: null,
onPull: null
};
/******************************************************************************/
var widget = uDom.nodeFromId('cloudWidget');
if ( widget === null ) {
return;
}
self.cloud.datakey = widget.getAttribute('data-cloud-entry') || '';
if ( self.cloud.datakey === '' ) {
return;
}
/******************************************************************************/
var messager = vAPI.messaging.channel('cloud-ui.js');
/******************************************************************************/
var onCloudDataReceived = function(entry) {
if ( typeof entry !== 'object' || entry === null ) {
return;
}
self.cloud.data = entry.data;
uDom.nodeFromId('cloudPull').removeAttribute('disabled');
var timeOptions = {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
};
var time = new Date(entry.tstamp);
widget.querySelector('span').textContent =
entry.source + '\n' +
time.toLocaleString('fullwide', timeOptions);
};
/******************************************************************************/
var fetchCloudData = function() {
messager.send(
{
what: 'cloudPull',
datakey: self.cloud.datakey
},
onCloudDataReceived
);
};
/******************************************************************************/
var pushData = function() {
if ( typeof self.cloud.onPush !== 'function' ) {
return;
}
messager.send(
{
what: 'cloudPush',
datakey: self.cloud.datakey,
data: self.cloud.onPush()
},
fetchCloudData
);
};
/******************************************************************************/
var pullData = function(ev) {
if ( typeof self.cloud.onPull === 'function' ) {
self.cloud.onPull(self.cloud.data, ev.shiftKey);
}
};
/******************************************************************************/
var openOptions = function() {
var input = uDom.nodeFromId('cloudDeviceName');
input.value = self.cloud.options.deviceName;
input.setAttribute('placeholder', self.cloud.options.defaultDeviceName);
uDom.nodeFromId('cloudOptions').classList.add('show');
};
/******************************************************************************/
var closeOptions = function(ev) {
var root = uDom.nodeFromId('cloudOptions');
if ( ev.target !== root ) {
return;
}
root.classList.remove('show');
};
/******************************************************************************/
var submitOptions = function() {
var onOptions = function(options) {
if ( typeof options !== 'object' || options === null ) {
return;
}
self.cloud.options = options;
};
messager.send({
what: 'cloudSetOptions',
options: {
deviceName: uDom.nodeFromId('cloudDeviceName').value
}
}, onOptions);
uDom.nodeFromId('cloudOptions').classList.remove('show');
};
/******************************************************************************/
var onInitialize = function(options) {
if ( typeof options !== 'object' || options === null ) {
return;
}
if ( !options.enabled ) {
return;
}
self.cloud.options = options;
fetchCloudData();
var html = [
'<button id="cloudPush" type="button" title="cloudPush"></button>',
'<span data-i18n="cloudNoData"></span>',
'<button id="cloudPull" type="button" title="cloudPull" disabled></button>',
'<span id="cloudCog" class="fa">&#xf013;</span>',
'<div id="cloudOptions">',
' <div>',
' <p><label data-i18n="cloudDeviceNamePrompt"></label> <input id="cloudDeviceName" type="text" value="">',
' <p><button id="cloudOptionsSubmit" type="button" data-i18n="genericSubmit"></button>',
' </div>',
'</div>',
].join('');
vAPI.insertHTML(widget, html);
vAPI.i18n.render(widget);
widget.classList.remove('hide');
uDom('#cloudPush').on('click', pushData);
uDom('#cloudPull').on('click', pullData);
uDom('#cloudCog').on('click', openOptions);
uDom('#cloudOptions').on('click', closeOptions);
uDom('#cloudOptionsSubmit').on('click', submitOptions);
};
messager.send({ what: 'cloudGetOptions' }, onInitialize);
/******************************************************************************/
// https://www.youtube.com/watch?v=aQFp67VoiDA
})();

@ -19,6 +19,8 @@
Home: https://github.com/gorhill/uMatrix Home: https://github.com/gorhill/uMatrix
*/ */
/* global vAPI, uDom */
/******************************************************************************/ /******************************************************************************/
// This file should always be included at the end of the `body` tag, so as // This file should always be included at the end of the `body` tag, so as
@ -30,46 +32,32 @@
/******************************************************************************/ /******************************************************************************/
var text; // Helper to deal with the i18n'ing of HTML files.
vAPI.i18n.render = function(context) {
var nodeList = document.querySelectorAll('[data-i18n]'); uDom('[data-i18n]', context).forEach(function(elem) {
var i = nodeList.length; elem.html(vAPI.i18n(elem.attr('data-i18n')));
var node; });
while ( i-- ) {
node = nodeList[i]; uDom('[title]', context).forEach(function(elem) {
vAPI.insertHTML(node, vAPI.i18n(node.getAttribute('data-i18n'))); var title = vAPI.i18n(elem.attr('title'));
} if ( title ) {
elem.attr('title', title);
// copy text of <h1> if any to document title }
node = document.querySelector('h1'); });
if ( node !== null ) {
document.title = node.textContent; uDom('[placeholder]', context).forEach(function(elem) {
} elem.attr('placeholder', vAPI.i18n(elem.attr('placeholder')));
});
// Tool tips
nodeList = document.querySelectorAll('[data-i18n-tip]'); uDom('[data-i18n-tip]', context).forEach(function(elem) {
i = nodeList.length; elem.attr(
while ( i-- ) { 'data-tip',
node = nodeList[i]; vAPI.i18n(elem.attr('data-i18n-tip')).replace(/<br>/g, '\n').replace(/\n{3,}/g, '\n\n')
node.setAttribute('data-tip', vAPI.i18n(node.getAttribute('data-i18n-tip'))); );
} });
nodeList = document.querySelectorAll('[title]'); };
i = nodeList.length;
while ( i-- ) { vAPI.i18n.render();
node = nodeList[i];
text = node.getAttribute('title');
node.setAttribute('title', vAPI.i18n(text) || text);
}
nodeList = document.querySelectorAll('input[placeholder]');
i = nodeList.length;
while ( i-- ) {
node = nodeList[i];
node.setAttribute(
'placeholder',
vAPI.i18n(node.getAttribute('placeholder')) || ''
);
}
/******************************************************************************/ /******************************************************************************/

@ -567,6 +567,68 @@ vAPI.messaging.listen('contentscript-end.js', onMessage);
/******************************************************************************/ /******************************************************************************/
/******************************************************************************/ /******************************************************************************/
// cloud-ui.js
(function() {
'use strict';
/******************************************************************************/
var µm = µMatrix;
/******************************************************************************/
var onMessage = function(request, sender, callback) {
// Async
switch ( request.what ) {
case 'cloudGetOptions':
vAPI.cloud.getOptions(function(options) {
options.enabled = µm.userSettings.cloudStorageEnabled === true;
callback(options);
});
return;
case 'cloudSetOptions':
vAPI.cloud.setOptions(request.options, callback);
return;
case 'cloudPull':
return vAPI.cloud.pull(request.datakey, callback);
case 'cloudPush':
return vAPI.cloud.push(request.datakey, request.data, callback);
default:
break;
}
// Sync
var response;
switch ( request.what ) {
// For when cloud storage is disabled.
case 'cloudPull':
// fallthrough
case 'cloudPush':
break;
default:
return vAPI.messaging.UNHANDLED;
}
callback(response);
};
/******************************************************************************/
vAPI.messaging.listen('cloud-ui.js', onMessage);
})();
/******************************************************************************/
/******************************************************************************/
// settings.js // settings.js
(function() { (function() {

@ -59,13 +59,17 @@ var processUserRules = function(response) {
i = rules.length; i = rules.length;
while ( i-- ) { while ( i-- ) {
rule = rules[i].trim(); rule = rules[i].trim();
permanentRules[rule] = allRules[rule] = true; if ( rule.length !== 0 ) {
permanentRules[rule] = allRules[rule] = true;
}
} }
rules = response.temporaryRules.split(/\n+/); rules = response.temporaryRules.split(/\n+/);
i = rules.length; i = rules.length;
while ( i-- ) { while ( i-- ) {
rule = rules[i].trim(); rule = rules[i].trim();
temporaryRules[rule] = allRules[rule] = true; if ( rule.length !== 0 ) {
temporaryRules[rule] = allRules[rule] = true;
}
} }
rules = Object.keys(allRules).sort(directiveSort); rules = Object.keys(allRules).sort(directiveSort);
for ( i = 0; i < rules.length; i++ ) { for ( i = 0; i < rules.length; i++ ) {
@ -78,7 +82,7 @@ var processUserRules = function(response) {
} else if ( onLeft ) { } else if ( onLeft ) {
permanentList.push('<li>', rule); permanentList.push('<li>', rule);
temporaryList.push('<li class="notRight toRemove">', rule); temporaryList.push('<li class="notRight toRemove">', rule);
} else { } else if ( onRight ) {
permanentList.push('<li>&nbsp;'); permanentList.push('<li>&nbsp;');
temporaryList.push('<li class="notLeft">', rule); temporaryList.push('<li class="notLeft">', rule);
} }
@ -153,7 +157,7 @@ var fromNoScript = function(content) {
/******************************************************************************/ /******************************************************************************/
function handleImportFilePicker() { var handleImportFilePicker = function() {
var fileReaderOnLoadHandler = function() { var fileReaderOnLoadHandler = function() {
if ( typeof this.result !== 'string' || this.result === '' ) { if ( typeof this.result !== 'string' || this.result === '' ) {
return; return;
@ -181,7 +185,7 @@ function handleImportFilePicker() {
var fr = new FileReader(); var fr = new FileReader();
fr.onload = fileReaderOnLoadHandler; fr.onload = fileReaderOnLoadHandler;
fr.readAsText(file); fr.readAsText(file);
} };
/******************************************************************************/ /******************************************************************************/
@ -281,6 +285,26 @@ var temporaryRulesToggler = function(ev) {
/******************************************************************************/ /******************************************************************************/
self.cloud.onPush = function() {
return rulesFromHTML('#diff .left li');
};
self.cloud.onPull = function(data, append) {
if ( typeof data !== 'string' ) {
return;
}
if ( append ) {
data = rulesFromHTML('#diff .right li') + '\n' + data;
}
var request = {
'what': 'setUserRules',
'temporaryRules': data
};
messager.send(request, processUserRules);
};
/******************************************************************************/
uDom.onLoad(function() { uDom.onLoad(function() {
// Handle user interaction // Handle user interaction
uDom('#importButton').on('click', startImportFilePicker); uDom('#importButton').on('click', startImportFilePicker);

@ -44,6 +44,9 @@ ul > li {
<li> <li>
<input id="collapseBlocked" type="checkbox" data-range="bool"> <input id="collapseBlocked" type="checkbox" data-range="bool">
<label data-i18n="settingsCollapseBlocked" for="collapseBlocked"></label> <label data-i18n="settingsCollapseBlocked" for="collapseBlocked"></label>
<li>
<input id="cloudStorageEnabled" type="checkbox" data-range="bool">
<label data-i18n="settingsCloudStorageEnabled" for="cloudStorageEnabled"></label>
</ul> </ul>

@ -5,11 +5,14 @@
<title>uMatrix — Your rules</title> <title>uMatrix — Your rules</title>
<link rel="stylesheet" type="text/css" href="css/common.css"> <link rel="stylesheet" type="text/css" href="css/common.css">
<link rel="stylesheet" type="text/css" href="css/dashboard-common.css"> <link rel="stylesheet" type="text/css" href="css/dashboard-common.css">
<link rel="stylesheet" type="text/css" href="css/cloud-ui.css">
<link rel="stylesheet" type="text/css" href="css/user-rules.css"> <link rel="stylesheet" type="text/css" href="css/user-rules.css">
</head> </head>
<body> <body>
<div id="cloudWidget" class="hide" data-cloud-entry="myRulesPane"></div>
<!-- <p data-i18n="userRulesFormatHint"></p> --> <!-- <p data-i18n="userRulesFormatHint"></p> -->
<div id="diff"> <div id="diff">
<div class="pane left"> <div class="pane left">
@ -42,6 +45,7 @@
<script src="js/udom.js"></script> <script src="js/udom.js"></script>
<script src="js/i18n.js"></script> <script src="js/i18n.js"></script>
<script src="js/dashboard-common.js"></script> <script src="js/dashboard-common.js"></script>
<script src="js/cloud-ui.js"></script>
<script src="js/user-rules.js"></script> <script src="js/user-rules.js"></script>
</body> </body>

Loading…
Cancel
Save