602 lines
17 KiB
JavaScript
602 lines
17 KiB
JavaScript
function getDatabase(ready, error) {
|
|
var dbOpenRequest = window.indexedDB.open("stylish", 2);
|
|
dbOpenRequest.onsuccess = function(e) {
|
|
ready(e.target.result);
|
|
};
|
|
dbOpenRequest.onerror = function(event) {
|
|
console.log(event.target.errorCode);
|
|
if (error) {
|
|
error(event);
|
|
}
|
|
};
|
|
dbOpenRequest.onupgradeneeded = function(event) {
|
|
if (event.oldVersion == 0) {
|
|
var os = event.target.result.createObjectStore("styles", {keyPath: 'id', autoIncrement: true});
|
|
webSqlStorage.migrate();
|
|
}
|
|
}
|
|
};
|
|
|
|
var cachedStyles = null;
|
|
function getStyles(options, callback) {
|
|
if (cachedStyles != null) {
|
|
callback(filterStyles(cachedStyles, options));
|
|
return;
|
|
}
|
|
getDatabase(function(db) {
|
|
var tx = db.transaction(["styles"], "readonly");
|
|
var os = tx.objectStore("styles");
|
|
var all = [];
|
|
os.openCursor().onsuccess = function(event) {
|
|
var cursor = event.target.result;
|
|
if (cursor) {
|
|
var s = cursor.value;
|
|
s.id = cursor.key;
|
|
all.push(cursor.value);
|
|
cursor.continue();
|
|
} else {
|
|
cachedStyles = all;
|
|
try{
|
|
callback(filterStyles(all, options));
|
|
} catch(e){
|
|
// no error in console, it works
|
|
}
|
|
}
|
|
};
|
|
}, null);
|
|
}
|
|
|
|
function invalidateCache(andNotify) {
|
|
cachedStyles = null;
|
|
if (andNotify) {
|
|
chrome.runtime.sendMessage({method: "invalidateCache"});
|
|
}
|
|
}
|
|
|
|
function filterStyles(styles, options) {
|
|
var enabled = fixBoolean(options.enabled);
|
|
var url = "url" in options ? options.url : null;
|
|
var id = "id" in options ? Number(options.id) : null;
|
|
var matchUrl = "matchUrl" in options ? options.matchUrl : null;
|
|
|
|
if (enabled != null) {
|
|
styles = styles.filter(function(style) {
|
|
return style.enabled == enabled;
|
|
});
|
|
}
|
|
if (url != null) {
|
|
styles = styles.filter(function(style) {
|
|
return style.url == url;
|
|
});
|
|
}
|
|
if (id != null) {
|
|
styles = styles.filter(function(style) {
|
|
return style.id == id;
|
|
});
|
|
}
|
|
if (matchUrl != null) {
|
|
// Return as a hash from style to applicable sections? Can only be used with matchUrl.
|
|
var asHash = "asHash" in options ? options.asHash : false;
|
|
if (asHash) {
|
|
var h = {disableAll: prefs.get("disableAll", false)};
|
|
styles.forEach(function(style) {
|
|
var applicableSections = getApplicableSections(style, matchUrl);
|
|
if (applicableSections.length > 0) {
|
|
h[style.id] = applicableSections;
|
|
}
|
|
});
|
|
return h;
|
|
}
|
|
styles = styles.filter(function(style) {
|
|
var applicableSections = getApplicableSections(style, matchUrl);
|
|
return applicableSections.length > 0;
|
|
});
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
function saveStyle(style, {notify = true} = {}) {
|
|
return new Promise(resolve => {
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readwrite');
|
|
const os = tx.objectStore('styles');
|
|
|
|
// Update
|
|
if (style.id) {
|
|
style.id = Number(style.id);
|
|
os.get(style.id).onsuccess = eventGet => {
|
|
const oldStyle = Object.assign({}, eventGet.target.result);
|
|
const codeIsUpdated = !styleSectionsEqual(style, oldStyle);
|
|
style = Object.assign(oldStyle, style);
|
|
os.put(style).onsuccess = eventPut => {
|
|
style.id = style.id || eventPut.target.result;
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleUpdated', style, codeIsUpdated});
|
|
}
|
|
invalidateCache(notify);
|
|
resolve(style);
|
|
};
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Create
|
|
style = Object.assign({
|
|
// Set optional things if they're undefined
|
|
enabled: true,
|
|
updateUrl: null,
|
|
md5Url: null,
|
|
url: null,
|
|
originalMd5: null,
|
|
}, style, {
|
|
// Set other optional things to empty array if they're undefined
|
|
sections: style.sections.map(section =>
|
|
Object.assign({
|
|
urls: [],
|
|
urlPrefixes: [],
|
|
domains: [],
|
|
regexps: [],
|
|
}, section)
|
|
),
|
|
})
|
|
// Make sure it's not null - that makes indexeddb sad
|
|
delete style.id;
|
|
os.add(style).onsuccess = event => {
|
|
invalidateCache(true);
|
|
// Give it the ID that was generated
|
|
style.id = event.target.result;
|
|
notifyAllTabs({method: 'styleAdded', style});
|
|
resolve(style);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
function enableStyle(id, enabled) {
|
|
saveStyle({id: id, enabled: enabled}).then(style => {
|
|
handleUpdate(style);
|
|
notifyAllTabs({method: "styleUpdated", style});
|
|
});
|
|
}
|
|
|
|
function deleteStyle(id, callback = function (){}) {
|
|
getDatabase(function(db) {
|
|
var tx = db.transaction(["styles"], "readwrite");
|
|
var os = tx.objectStore("styles");
|
|
var request = os.delete(Number(id));
|
|
request.onsuccess = function(event) {
|
|
handleDelete(id);
|
|
invalidateCache(true);
|
|
notifyAllTabs({method: "styleDeleted", id: id});
|
|
callback();
|
|
};
|
|
});
|
|
}
|
|
|
|
function reportError() {
|
|
for (i in arguments) {
|
|
if ("message" in arguments[i]) {
|
|
//alert(arguments[i].message);
|
|
console.log(arguments[i].message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function fixBoolean(b) {
|
|
if (typeof b != "undefined") {
|
|
return b != "false";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getDomains(url) {
|
|
if (url.indexOf("file:") == 0) {
|
|
return [];
|
|
}
|
|
var d = /.*?:\/*([^\/:]+)/.exec(url)[1];
|
|
var domains = [d];
|
|
while (d.indexOf(".") != -1) {
|
|
d = d.substring(d.indexOf(".") + 1);
|
|
domains.push(d);
|
|
}
|
|
return domains;
|
|
}
|
|
|
|
function getType(o) {
|
|
if (typeof o == "undefined" || typeof o == "string") {
|
|
return typeof o;
|
|
}
|
|
if (o instanceof Array) {
|
|
return "array";
|
|
}
|
|
throw "Not supported - " + o;
|
|
}
|
|
|
|
var namespacePattern = /^\s*(@namespace[^;]+;\s*)+$/;
|
|
function getApplicableSections(style, url) {
|
|
var sections = style.sections.filter(function(section) {
|
|
return sectionAppliesToUrl(section, url);
|
|
});
|
|
// ignore if it's just namespaces
|
|
if (sections.length == 1 && namespacePattern.test(sections[0].code)) {
|
|
return [];
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
function sectionAppliesToUrl(section, url) {
|
|
// only http, https, file, and chrome-extension allowed
|
|
if (url.indexOf("http") != 0 && url.indexOf("file") != 0 && url.indexOf("chrome-extension") != 0 && url.indexOf("ftp") != 0) {
|
|
return false;
|
|
}
|
|
// other extensions can't be styled
|
|
if (url.indexOf("chrome-extension") == 0 && url.indexOf(chrome.extension.getURL("")) != 0) {
|
|
return false;
|
|
}
|
|
if (section.urls.length == 0 && section.domains.length == 0 && section.urlPrefixes.length == 0 && section.regexps.length == 0) {
|
|
//console.log(section.id + " is global");
|
|
return true;
|
|
}
|
|
if (section.urls.indexOf(url) != -1) {
|
|
//console.log(section.id + " applies to " + url + " due to URL rules");
|
|
return true;
|
|
}
|
|
if (section.urlPrefixes.some(function(prefix) {
|
|
return url.indexOf(prefix) == 0;
|
|
})) {
|
|
//console.log(section.id + " applies to " + url + " due to URL prefix rules");
|
|
return true;
|
|
}
|
|
if (section.domains.length > 0 && getDomains(url).some(function(domain) {
|
|
return section.domains.indexOf(domain) != -1;
|
|
})) {
|
|
//console.log(section.id + " applies due to " + url + " due to domain rules");
|
|
return true;
|
|
}
|
|
if (section.regexps.some(function(regexp) {
|
|
// we want to match the full url, so add ^ and $ if not already present
|
|
if (regexp[0] != "^") {
|
|
regexp = "^" + regexp;
|
|
}
|
|
if (regexp[regexp.length - 1] != "$") {
|
|
regexp += "$";
|
|
}
|
|
var re = runTryCatch(function() { return new RegExp(regexp) });
|
|
if (re) {
|
|
return (re).test(url);
|
|
} else {
|
|
console.log(section.id + "'s regexp '" + regexp + "' is not valid");
|
|
}
|
|
})) {
|
|
//console.log(section.id + " applies to " + url + " due to regexp rules");
|
|
return true;
|
|
}
|
|
//console.log(section.id + " does not apply due to " + url);
|
|
return false;
|
|
}
|
|
|
|
function isCheckbox(el) {
|
|
return el.nodeName.toLowerCase() == "input" && "checkbox" == el.type.toLowerCase();
|
|
}
|
|
|
|
// js engine can't optimize the entire function if it contains try-catch
|
|
// so we should keep it isolated from normal code in a minimal wrapper
|
|
function runTryCatch(func) {
|
|
try { return func() }
|
|
catch(e) {}
|
|
}
|
|
|
|
// Accepts an array of pref names (values are fetched via prefs.get)
|
|
// and establishes a two-way connection between the document elements and the actual prefs
|
|
function setupLivePrefs(IDs) {
|
|
var localIDs = {};
|
|
IDs.forEach(function(id) {
|
|
localIDs[id] = true;
|
|
updateElement(id).addEventListener("change", function() {
|
|
prefs.set(this.id, isCheckbox(this) ? this.checked : this.value);
|
|
});
|
|
});
|
|
chrome.runtime.onMessage.addListener(function(request) {
|
|
if (request.prefName in localIDs) {
|
|
updateElement(request.prefName);
|
|
}
|
|
});
|
|
function updateElement(id) {
|
|
var el = document.getElementById(id);
|
|
el[isCheckbox(el) ? "checked" : "value"] = prefs.get(id);
|
|
el.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
|
|
return el;
|
|
}
|
|
}
|
|
|
|
var prefs = chrome.extension.getBackgroundPage().prefs || new function Prefs() {
|
|
var me = this;
|
|
|
|
var defaults = {
|
|
"openEditInWindow": false, // new editor opens in a own browser window
|
|
"windowPosition": {}, // detached window position
|
|
"show-badge": true, // display text on popup menu icon
|
|
"disableAll": false, // boss key
|
|
|
|
"popup.breadcrumbs": true, // display "New style" links as URL breadcrumbs
|
|
"popup.breadcrumbs.usePath": false, // use URL path for "this URL"
|
|
"popup.enabledFirst": true, // display enabled styles before disabled styles
|
|
"popup.stylesFirst": true, // display enabled styles before disabled styles
|
|
|
|
"manage.onlyEnabled": false, // display only enabled styles
|
|
"manage.onlyEdited": false, // display only styles created locally
|
|
|
|
"editor.options": {}, // CodeMirror.defaults.*
|
|
"editor.lineWrapping": true, // word wrap
|
|
"editor.smartIndent": true, // "smart" indent
|
|
"editor.indentWithTabs": false, // smart indent with tabs
|
|
"editor.tabSize": 4, // tab width, in spaces
|
|
"editor.keyMap": navigator.appVersion.indexOf("Windows") > 0 ? "sublime" : "default",
|
|
"editor.theme": "default", // CSS theme
|
|
"editor.beautify": { // CSS beautifier
|
|
selector_separator_newline: true,
|
|
newline_before_open_brace: false,
|
|
newline_after_open_brace: true,
|
|
newline_between_properties: true,
|
|
newline_before_close_brace: true,
|
|
newline_between_rules: false,
|
|
end_with_newline: false
|
|
},
|
|
"editor.lintDelay": 500, // lint gutter marker update delay, ms
|
|
"editor.lintReportDelay": 4500, // lint report update delay, ms
|
|
|
|
"badgeDisabled": "#8B0000", // badge background color when disabled
|
|
"badgeNormal": "#006666", // badge background color
|
|
|
|
"popupWidth": 240, // popup width in pixels
|
|
|
|
"updateInterval": 0 // user-style automatic update interval, hour
|
|
};
|
|
var values = deepCopy(defaults);
|
|
|
|
var syncTimeout; // see broadcast() function below
|
|
|
|
Object.defineProperty(this, "readOnlyValues", {value: {}});
|
|
|
|
Prefs.prototype.get = function(key, defaultValue) {
|
|
if (key in values) {
|
|
return values[key];
|
|
}
|
|
if (defaultValue !== undefined) {
|
|
return defaultValue;
|
|
}
|
|
if (key in defaults) {
|
|
return defaults[key];
|
|
}
|
|
console.warn("No default preference for '%s'", key);
|
|
};
|
|
|
|
Prefs.prototype.getAll = function(key) {
|
|
return deepCopy(values);
|
|
};
|
|
|
|
Prefs.prototype.set = function(key, value, options) {
|
|
var oldValue = deepCopy(values[key]);
|
|
values[key] = value;
|
|
defineReadonlyProperty(this.readOnlyValues, key, value);
|
|
if ((!options || !options.noBroadcast) && !equal(value, oldValue)) {
|
|
me.broadcast(key, value, options);
|
|
}
|
|
};
|
|
|
|
Prefs.prototype.remove = function(key) { me.set(key, undefined) };
|
|
|
|
Prefs.prototype.broadcast = function(key, value, options) {
|
|
var message = {method: "prefChanged", prefName: key, value: value};
|
|
notifyAllTabs(message);
|
|
chrome.runtime.sendMessage(message);
|
|
if (key == "disableAll") {
|
|
notifyAllTabs({method: "styleDisableAll", disableAll: value});
|
|
}
|
|
if (!options || !options.noSync) {
|
|
clearTimeout(syncTimeout);
|
|
syncTimeout = setTimeout(function() {
|
|
getSync().set({"settings": values});
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
Object.keys(defaults).forEach(function(key) {
|
|
me.set(key, defaults[key], {noBroadcast: true});
|
|
});
|
|
|
|
getSync().get("settings", function(result) {
|
|
var synced = result.settings;
|
|
for (var key in defaults) {
|
|
if (synced && (key in synced)) {
|
|
me.set(key, synced[key], {noSync: true});
|
|
} else {
|
|
var value = tryMigrating(key);
|
|
if (value !== undefined) {
|
|
me.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
// make sure right click context menu is in the right state when prefs are loaded
|
|
chrome.contextMenus.update("disableAll", {checked: prefs.get("disableAll")});
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener(function(changes, area) {
|
|
if (area == "sync" && "settings" in changes) {
|
|
var synced = changes.settings.newValue;
|
|
if (synced) {
|
|
for (key in defaults) {
|
|
if (key in synced) {
|
|
me.set(key, synced[key], {noSync: true});
|
|
}
|
|
}
|
|
} else {
|
|
// user manually deleted our settings, we'll recreate them
|
|
getSync().set({"settings": values});
|
|
}
|
|
}
|
|
});
|
|
|
|
function tryMigrating(key) {
|
|
if (!(key in localStorage)) {
|
|
return undefined;
|
|
}
|
|
var value = localStorage[key];
|
|
delete localStorage[key];
|
|
localStorage["DEPRECATED: " + key] = value;
|
|
switch (typeof defaults[key]) {
|
|
case "boolean":
|
|
return value.toLowerCase() === "true";
|
|
case "number":
|
|
return Number(value);
|
|
case "object":
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch(e) {
|
|
console.log("Cannot migrate from localStorage %s = '%s': %o", key, value, e);
|
|
return undefined;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
};
|
|
|
|
function getCodeMirrorThemes(callback) {
|
|
chrome.runtime.getPackageDirectoryEntry(function(rootDir) {
|
|
rootDir.getDirectory("codemirror/theme", {create: false}, function(themeDir) {
|
|
themeDir.createReader().readEntries(function(entries) {
|
|
var themes = [chrome.i18n.getMessage("defaultTheme")];
|
|
entries
|
|
.filter(function(entry) { return entry.isFile })
|
|
.sort(function(a, b) { return a.name < b.name ? -1 : 1 })
|
|
.forEach(function(entry) {
|
|
themes.push(entry.name.replace(/\.css$/, ""));
|
|
});
|
|
if (callback) {
|
|
callback(themes);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function sessionStorageHash(name) {
|
|
var hash = {
|
|
value: {},
|
|
set: function(k, v) { this.value[k] = v; this.updateStorage(); },
|
|
unset: function(k) { delete this.value[k]; this.updateStorage(); },
|
|
updateStorage: function() {
|
|
sessionStorage[this.name] = JSON.stringify(this.value);
|
|
}
|
|
};
|
|
try { hash.value = JSON.parse(sessionStorage[name]); } catch(e) {}
|
|
Object.defineProperty(hash, "name", {value: name});
|
|
return hash;
|
|
}
|
|
|
|
function deepCopy(obj) {
|
|
if (!obj || typeof obj != "object") {
|
|
return obj;
|
|
} else {
|
|
var emptyCopy = Object.create(Object.getPrototypeOf(obj));
|
|
return deepMerge(emptyCopy, obj);
|
|
}
|
|
}
|
|
|
|
function deepMerge(target, obj1 /* plus any number of object arguments */) {
|
|
for (var i = 1; i < arguments.length; i++) {
|
|
var obj = arguments[i];
|
|
for (var k in obj) {
|
|
// hasOwnProperty checking is not needed for our non-OOP stuff
|
|
var value = obj[k];
|
|
if (!value || typeof value != "object") {
|
|
target[k] = value;
|
|
} else if (k in target) {
|
|
deepMerge(target[k], value);
|
|
} else {
|
|
target[k] = deepCopy(value);
|
|
}
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function equal(a, b) {
|
|
if (!a || !b || typeof a != "object" || typeof b != "object") {
|
|
return a === b;
|
|
}
|
|
if (Object.keys(a).length != Object.keys(b).length) {
|
|
return false;
|
|
}
|
|
for (var k in a) {
|
|
if (a[k] !== b[k]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function defineReadonlyProperty(obj, key, value) {
|
|
var copy = deepCopy(value);
|
|
// In ES6, freezing a literal is OK (it returns the same value), but in previous versions it's an exception.
|
|
if (typeof copy == "object") {
|
|
Object.freeze(copy);
|
|
}
|
|
Object.defineProperty(obj, key, {value: copy, configurable: true})
|
|
}
|
|
|
|
// Polyfill, can be removed when Firefox gets this - https://bugzilla.mozilla.org/show_bug.cgi?id=1220494
|
|
function getSync() {
|
|
if ("sync" in chrome.storage) {
|
|
return chrome.storage.sync;
|
|
}
|
|
crappyStorage = {};
|
|
return {
|
|
get: function(key, callback) {
|
|
callback(crappyStorage[key] || {});
|
|
},
|
|
set: function(source, callback) {
|
|
for (var property in source) {
|
|
if (source.hasOwnProperty(property)) {
|
|
crappyStorage[property] = source[property];
|
|
}
|
|
}
|
|
callback();
|
|
}
|
|
}
|
|
}
|
|
|
|
function styleSectionsEqual(styleA, styleB) {
|
|
if (!styleA.sections || !styleB.sections) {
|
|
return undefined;
|
|
}
|
|
if (styleA.sections.length != styleB.sections.length) {
|
|
return false;
|
|
}
|
|
const properties = ['code', 'urlPrefixes', 'urls', 'domains', 'regexps'];
|
|
return styleA.sections.every(sectionA =>
|
|
styleB.sections.some(sectionB =>
|
|
properties.every(property => sectionEquals(sectionA, sectionB, property))
|
|
)
|
|
);
|
|
|
|
function sectionEquals(a, b, property) {
|
|
const aProp = a[property], typeA = getType(aProp);
|
|
const bProp = b[property], typeB = getType(bProp);
|
|
if (typeA != typeB) {
|
|
// consider empty arrays equivalent to lack of property
|
|
return ((typeA == 'undefined' || (typeA == 'array' && aProp.length == 0)) &&
|
|
(typeB == 'undefined' || (typeB == 'array' && bProp.length == 0)));
|
|
}
|
|
if (typeA == 'undefined') {
|
|
return true;
|
|
}
|
|
if (typeA == 'array') {
|
|
return aProp.length == bProp.length && aProp.every(item => bProp.includes(item));
|
|
}
|
|
if (typeA == 'string') {
|
|
return aProp == bProp;
|
|
}
|
|
}
|
|
}
|