959 lines
28 KiB
JavaScript
959 lines
28 KiB
JavaScript
/* global cachedStyles: true, prefs: true, contextMenus: false */
|
|
/* global handleUpdate, handleDelete */
|
|
'use strict';
|
|
|
|
function getDatabase(ready, error) {
|
|
const dbOpenRequest = window.indexedDB.open('stylish', 2);
|
|
dbOpenRequest.onsuccess = event => {
|
|
ready(event.target.result);
|
|
};
|
|
dbOpenRequest.onerror = event => {
|
|
console.warn(event.target.errorCode);
|
|
if (error) {
|
|
error(event);
|
|
}
|
|
};
|
|
dbOpenRequest.onupgradeneeded = event => {
|
|
if (event.oldVersion == 0) {
|
|
event.target.result.createObjectStore('styles', {
|
|
keyPath: 'id',
|
|
autoIncrement: true,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
const RX_NAMESPACE = new RegExp([/[\s\r\n]*/,
|
|
/(@namespace[\s\r\n]+(?:[^\s\r\n]+[\s\r\n]+)?url\(http:\/\/.*?\);)/,
|
|
/[\s\r\n]*/].map(rx => rx.source).join(''), 'g');
|
|
const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g;
|
|
const SLOPPY_REGEXP_PREFIX = '\0';
|
|
|
|
// Let manage/popup/edit reuse background page variables
|
|
// Note, only 'var'-declared variables are visible from another extension page
|
|
// eslint-disable-next-line no-var
|
|
var cachedStyles, prefs;
|
|
(() => {
|
|
const bg = chrome.extension.getBackgroundPage();
|
|
cachedStyles = bg && bg.cachedStyles || {
|
|
bg,
|
|
list: null,
|
|
byId: new Map(),
|
|
filters: new Map(),
|
|
regexps: new Map(),
|
|
urlDomains: new Map(),
|
|
emptyCode: new Map(), // entire code is comments/whitespace/@namespace
|
|
mutex: {
|
|
inProgress: false,
|
|
onDone: [],
|
|
},
|
|
};
|
|
prefs = bg && bg.prefs;
|
|
})();
|
|
|
|
|
|
// in case Chrome haven't yet loaded the bg page and displays our page like edit/manage
|
|
function getStylesSafe(options) {
|
|
return new Promise(resolve => {
|
|
if (cachedStyles.bg) {
|
|
getStyles(options, resolve);
|
|
return;
|
|
}
|
|
chrome.runtime.sendMessage(Object.assign({method: 'getStyles'}, options), styles => {
|
|
if (!styles) {
|
|
resolve(getStylesSafe(options));
|
|
} else {
|
|
cachedStyles = chrome.extension.getBackgroundPage().cachedStyles;
|
|
resolve(styles);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
function getStyles(options, callback) {
|
|
if (cachedStyles.list) {
|
|
callback(filterStyles(options));
|
|
return;
|
|
}
|
|
if (cachedStyles.mutex.inProgress) {
|
|
cachedStyles.mutex.onDone.push({options, callback});
|
|
return;
|
|
}
|
|
cachedStyles.mutex.inProgress = true;
|
|
|
|
//const t0 = performance.now();
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readonly');
|
|
const os = tx.objectStore('styles');
|
|
os.getAll().onsuccess = event => {
|
|
cachedStyles.list = event.target.result || [];
|
|
cachedStyles.byId.clear();
|
|
for (const style of cachedStyles.list) {
|
|
cachedStyles.byId.set(style.id, style);
|
|
compileStyleRegExps({style});
|
|
}
|
|
//console.debug('%s getStyles %s, invoking cached callbacks: %o', (performance.now() - t0).toFixed(1), JSON.stringify(options), cachedStyles.mutex.onDone.map(e => JSON.stringify(e.options))); // eslint-disable-line max-len
|
|
callback(filterStyles(options));
|
|
|
|
cachedStyles.mutex.inProgress = false;
|
|
for (const {options, callback} of cachedStyles.mutex.onDone) {
|
|
callback(filterStyles(options));
|
|
}
|
|
cachedStyles.mutex.onDone = [];
|
|
};
|
|
}, null);
|
|
}
|
|
|
|
|
|
function getStyleWithNoCode(style) {
|
|
const stripped = Object.assign({}, style, {sections: []});
|
|
for (const section of style.sections) {
|
|
stripped.sections.push(Object.assign({}, section, {code: null}));
|
|
}
|
|
return stripped;
|
|
}
|
|
|
|
|
|
function invalidateCache(andNotify, {added, updated, deletedId} = {}) {
|
|
// prevent double-add on echoed invalidation
|
|
const cached = added && cachedStyles.byId.get(added.id);
|
|
if (cached) {
|
|
return;
|
|
}
|
|
if (andNotify) {
|
|
chrome.runtime.sendMessage({method: 'invalidateCache', added, updated, deletedId});
|
|
}
|
|
if (!cachedStyles.list) {
|
|
return;
|
|
}
|
|
if (updated) {
|
|
const cached = cachedStyles.byId.get(updated.id);
|
|
if (cached) {
|
|
Object.assign(cached, updated);
|
|
//console.debug('cache: updated', updated);
|
|
}
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
if (added) {
|
|
cachedStyles.list.push(added);
|
|
cachedStyles.byId.set(added.id, added);
|
|
//console.debug('cache: added', added);
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
if (deletedId != undefined) {
|
|
const deletedStyle = (cachedStyles.byId.get(deletedId) || {}).style;
|
|
if (deletedStyle) {
|
|
const cachedIndex = cachedStyles.list.indexOf(deletedStyle);
|
|
cachedStyles.list.splice(cachedIndex, 1);
|
|
cachedStyles.byId.delete(deletedId);
|
|
//console.debug('cache: deleted', deletedStyle);
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
}
|
|
cachedStyles.list = null;
|
|
//console.debug('cache cleared');
|
|
cachedStyles.filters.clear();
|
|
}
|
|
|
|
|
|
function filterStyles({
|
|
enabled,
|
|
url = null,
|
|
id = null,
|
|
matchUrl = null,
|
|
asHash = null,
|
|
strictRegexp = true, // used by the popup to detect bad regexps
|
|
} = {}) {
|
|
//const t0 = performance.now();
|
|
enabled = fixBoolean(enabled);
|
|
id = id === null ? null : Number(id);
|
|
|
|
if (enabled === null
|
|
&& url === null
|
|
&& id === null
|
|
&& matchUrl === null
|
|
&& asHash != true) {
|
|
//console.debug('%c%s filterStyles SKIPPED LOOP %s', 'color:gray', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len
|
|
return cachedStyles.list;
|
|
}
|
|
// silence the inapplicable warning for async code
|
|
// eslint-disable-next-line no-use-before-define
|
|
const disableAll = asHash && prefs.get('disableAll', false);
|
|
|
|
// add \t after url to prevent collisions (not sure it can actually happen though)
|
|
const cacheKey = ' ' + enabled + url + '\t' + id + matchUrl + '\t' + asHash + strictRegexp;
|
|
const cached = cachedStyles.filters.get(cacheKey);
|
|
if (cached) {
|
|
//console.debug('%c%s filterStyles REUSED RESPONSE %s', 'color:gray', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len
|
|
cached.hits++;
|
|
cached.lastHit = Date.now();
|
|
|
|
return asHash
|
|
? Object.assign({disableAll}, cached.styles)
|
|
: cached.styles;
|
|
}
|
|
|
|
if (matchUrl && !cachedStyles.urlDomains.has(matchUrl)) {
|
|
cachedStyles.urlDomains.set(matchUrl, getDomains(matchUrl));
|
|
for (let i = cachedStyles.urlDomains.size - 100; i > 0; i--) {
|
|
const firstKey = cachedStyles.urlDomains.keys().next().value;
|
|
cachedStyles.urlDomains.delete(firstKey);
|
|
}
|
|
}
|
|
|
|
const styles = id === null
|
|
? cachedStyles.list
|
|
: [cachedStyles.byId.get(id)];
|
|
const filtered = asHash ? {} : [];
|
|
if (!styles) {
|
|
// may happen when users [accidentally] reopen an old URL
|
|
// of edit.html with a non-existent style id parameter
|
|
return filtered;
|
|
}
|
|
const needSections = asHash || matchUrl !== null;
|
|
|
|
for (let i = 0, style; (style = styles[i]); i++) {
|
|
if ((enabled === null || style.enabled == enabled)
|
|
&& (url === null || style.url == url)
|
|
&& (id === null || style.id == id)) {
|
|
const sections = needSections &&
|
|
getApplicableSections({style, matchUrl, strictRegexp, stopOnFirst: !asHash});
|
|
if (asHash) {
|
|
if (sections.length) {
|
|
filtered[style.id] = sections;
|
|
}
|
|
} else if (matchUrl === null || sections.length) {
|
|
filtered.push(style);
|
|
}
|
|
}
|
|
}
|
|
//console.debug('%s filterStyles %s', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len
|
|
cachedStyles.filters.set(cacheKey, {
|
|
styles: filtered,
|
|
lastHit: Date.now(),
|
|
hits: 1,
|
|
});
|
|
if (cachedStyles.filters.size > 10000) {
|
|
cleanupCachedFilters();
|
|
}
|
|
return asHash
|
|
? Object.assign({disableAll}, filtered)
|
|
: filtered;
|
|
}
|
|
|
|
|
|
function cleanupCachedFilters({force = false} = {}) {
|
|
if (!force) {
|
|
// sliding timer for 1 second
|
|
clearTimeout(cleanupCachedFilters.timeout);
|
|
cleanupCachedFilters.timeout = setTimeout(cleanupCachedFilters, 1000, {force: true});
|
|
return;
|
|
}
|
|
const size = cachedStyles.filters.size;
|
|
const oldestHit = cachedStyles.filters.values().next().value.lastHit;
|
|
const now = Date.now();
|
|
const timeSpan = now - oldestHit;
|
|
const recencyWeight = 5 / size;
|
|
const hitWeight = 1 / 4; // we make ~4 hits per URL
|
|
const lastHitWeight = 10;
|
|
// delete the oldest 10%
|
|
[...cachedStyles.filters.entries()]
|
|
.map(([id, v], index) => ({
|
|
id,
|
|
weight:
|
|
index * recencyWeight +
|
|
v.hits * hitWeight +
|
|
(v.lastHit - oldestHit) / timeSpan * lastHitWeight,
|
|
}))
|
|
.sort((a, b) => a.weight - b.weight)
|
|
.slice(0, size / 10 + 1)
|
|
.forEach(({id}) => cachedStyles.filters.delete(id));
|
|
cleanupCachedFilters.timeout = 0;
|
|
}
|
|
|
|
|
|
function saveStyle(style) {
|
|
return new Promise(resolve => {
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readwrite');
|
|
const os = tx.objectStore('styles');
|
|
|
|
const id = style.id !== undefined && style.id !== null ? Number(style.id) : null;
|
|
const reason = style.reason;
|
|
const notify = style.notify !== false;
|
|
delete style.method;
|
|
delete style.reason;
|
|
delete style.notify;
|
|
if (!style.name) {
|
|
delete style.name;
|
|
}
|
|
|
|
// Update
|
|
if (id !== null) {
|
|
style.id = id;
|
|
os.get(id).onsuccess = eventGet => {
|
|
const existed = Boolean(eventGet.target.result);
|
|
const oldStyle = Object.assign({}, eventGet.target.result);
|
|
const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle);
|
|
style = Object.assign(oldStyle, style);
|
|
addMissingStyleTargets(style);
|
|
os.put(style).onsuccess = eventPut => {
|
|
style.id = style.id || eventPut.target.result;
|
|
invalidateCache(notify, existed ? {updated: style} : {added: style});
|
|
compileStyleRegExps({style});
|
|
if (notify) {
|
|
notifyAllTabs({
|
|
method: existed ? 'styleUpdated' : 'styleAdded',
|
|
style, codeIsUpdated, reason,
|
|
});
|
|
}
|
|
if (typeof handleUpdate != 'undefined') {
|
|
handleUpdate(style, {reason});
|
|
}
|
|
resolve(style);
|
|
};
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Create
|
|
delete style.id;
|
|
style = Object.assign({
|
|
// Set optional things if they're undefined
|
|
enabled: true,
|
|
updateUrl: null,
|
|
md5Url: null,
|
|
url: null,
|
|
originalMd5: null,
|
|
}, style);
|
|
addMissingStyleTargets(style);
|
|
os.add(style).onsuccess = event => {
|
|
// Give it the ID that was generated
|
|
style.id = event.target.result;
|
|
invalidateCache(notify, {added: style});
|
|
compileStyleRegExps({style});
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleAdded', style, reason});
|
|
}
|
|
if (typeof handleUpdate != 'undefined') {
|
|
handleUpdate(style, {reason});
|
|
}
|
|
resolve(style);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
function addMissingStyleTargets(style) {
|
|
style.sections = (style.sections || []).map(section =>
|
|
Object.assign({
|
|
urls: [],
|
|
urlPrefixes: [],
|
|
domains: [],
|
|
regexps: [],
|
|
}, section)
|
|
);
|
|
}
|
|
|
|
|
|
function enableStyle(id, enabled) {
|
|
return saveStyle({id, enabled});
|
|
}
|
|
|
|
|
|
function deleteStyle(id, {notify = true} = {}) {
|
|
return new Promise(resolve =>
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readwrite');
|
|
const os = tx.objectStore('styles');
|
|
os.delete(Number(id)).onsuccess = () => {
|
|
invalidateCache(notify, {deletedId: id});
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleDeleted', id});
|
|
}
|
|
if (typeof handleDelete != 'undefined') {
|
|
handleDelete(id);
|
|
}
|
|
resolve(id);
|
|
};
|
|
}));
|
|
}
|
|
|
|
|
|
function reportError(...args) {
|
|
for (const arg of args) {
|
|
if ('message' in arg) {
|
|
console.log(arg.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function fixBoolean(b) {
|
|
if (typeof b != 'undefined') {
|
|
return b != 'false';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
function getDomains(url) {
|
|
if (url.indexOf('file:') == 0) {
|
|
return [];
|
|
}
|
|
let d = /.*?:\/*([^/:]+)/.exec(url)[1];
|
|
const 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;
|
|
}
|
|
// with the persistent cachedStyles the Array reference is usually different
|
|
// so let's check for e.g. type of 'every' which is only present on arrays
|
|
// (in the context of our extension)
|
|
if (o instanceof Array || typeof o.every == 'function') {
|
|
return 'array';
|
|
}
|
|
console.warn('Unsupported type:', o);
|
|
return 'undefined';
|
|
}
|
|
|
|
|
|
function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) {
|
|
//let t0 = 0;
|
|
const sections = [];
|
|
checkingSections:
|
|
for (const section of style.sections) {
|
|
andCollect:
|
|
do {
|
|
// only http, https, file, ftp, and chrome-extension://OWN_EXTENSION_ID allowed
|
|
if (!matchUrl.startsWith('http')
|
|
&& !matchUrl.startsWith('ftp')
|
|
&& !matchUrl.startsWith('file')
|
|
&& !matchUrl.startsWith(OWN_ORIGIN)) {
|
|
continue checkingSections;
|
|
}
|
|
if (section.urls.length == 0
|
|
&& section.domains.length == 0
|
|
&& section.urlPrefixes.length == 0
|
|
&& section.regexps.length == 0) {
|
|
break andCollect;
|
|
}
|
|
if (section.urls.indexOf(matchUrl) != -1) {
|
|
break andCollect;
|
|
}
|
|
for (const urlPrefix of section.urlPrefixes) {
|
|
if (matchUrl.startsWith(urlPrefix)) {
|
|
break andCollect;
|
|
}
|
|
}
|
|
if (section.domains.length) {
|
|
const urlDomains = cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl);
|
|
for (const domain of urlDomains) {
|
|
if (section.domains.indexOf(domain) != -1) {
|
|
break andCollect;
|
|
}
|
|
}
|
|
}
|
|
for (const regexp of section.regexps) {
|
|
for (let pass = 1; pass <= (strictRegexp ? 1 : 2); pass++) {
|
|
const cacheKey = pass == 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp;
|
|
let rx = cachedStyles.regexps.get(cacheKey);
|
|
if (rx == false) {
|
|
// invalid regexp
|
|
break;
|
|
}
|
|
if (!rx) {
|
|
const anchored = pass == 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
|
|
rx = tryRegExp(anchored);
|
|
cachedStyles.regexps.set(cacheKey, rx || false);
|
|
if (!rx) {
|
|
// invalid regexp
|
|
break;
|
|
}
|
|
}
|
|
if (rx.test(matchUrl)) {
|
|
break andCollect;
|
|
}
|
|
}
|
|
}
|
|
continue checkingSections;
|
|
} while (0);
|
|
// Collect the section if not empty or namespace-only.
|
|
// We don't check long code as it's slow both for emptyCode declared as Object
|
|
// and as Map in case the string is not the same reference used to add the item
|
|
//const t0start = performance.now();
|
|
const code = section.code;
|
|
let isEmpty = code !== null && code.length < 1000 && cachedStyles.emptyCode.get(code);
|
|
if (isEmpty === undefined) {
|
|
isEmpty = !code || !code.trim()
|
|
|| code.indexOf('@namespace') >= 0
|
|
&& code.replace(RX_CSS_COMMENTS, '').replace(RX_NAMESPACE, '').trim() == '';
|
|
cachedStyles.emptyCode.set(code, isEmpty);
|
|
}
|
|
//t0 += performance.now() - t0start;
|
|
if (!isEmpty) {
|
|
sections.push(section);
|
|
if (stopOnFirst) {
|
|
//t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions
|
|
return sections;
|
|
}
|
|
}
|
|
}
|
|
//t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions
|
|
return sections;
|
|
}
|
|
|
|
|
|
function isCheckbox(el) {
|
|
return el.localName == 'input' && el.type == 'checkbox';
|
|
}
|
|
|
|
|
|
// 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
|
|
// Update: might get fixed in V8 TurboFan in the future
|
|
function runTryCatch(func, ...args) {
|
|
try {
|
|
return func(...args);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function tryRegExp(regexp) {
|
|
try {
|
|
return new RegExp(regexp);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function tryJSONparse(jsonString) {
|
|
try {
|
|
return JSON.parse(jsonString);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function debounce(fn, ...args) {
|
|
const timers = debounce.timers = debounce.timers || new Map();
|
|
debounce.run = debounce.run || ((fn, ...args) => {
|
|
timers.delete(fn);
|
|
fn(...args);
|
|
});
|
|
clearTimeout(timers.get(fn));
|
|
timers.set(fn, setTimeout(debounce.run, 0, fn, ...args));
|
|
}
|
|
|
|
|
|
prefs = prefs || new function Prefs() {
|
|
const 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,
|
|
space_around_selector_separator: true,
|
|
},
|
|
'editor.lintDelay': 500, // lint gutter marker update delay, ms
|
|
'editor.lintReportDelay': 4500, // lint report update delay, ms
|
|
'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected
|
|
// selection = only when something is selected
|
|
// '' (empty string) = disabled
|
|
|
|
'badgeDisabled': '#8B0000', // badge background color when disabled
|
|
'badgeNormal': '#006666', // badge background color
|
|
|
|
'popupWidth': 246, // popup width in pixels
|
|
|
|
'updateInterval': 0 // user-style automatic update interval, hour
|
|
};
|
|
const values = deepCopy(defaults);
|
|
|
|
// coalesce multiple pref changes in broadcast
|
|
let broadcastPrefs = {};
|
|
|
|
function doBroadcast() {
|
|
notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs});
|
|
broadcastPrefs = {};
|
|
}
|
|
|
|
function doSyncSet() {
|
|
getSync().set({'settings': values});
|
|
}
|
|
|
|
Object.defineProperty(this, 'readOnlyValues', {value: {}});
|
|
|
|
Object.assign(Prefs.prototype, {
|
|
|
|
get(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);
|
|
},
|
|
|
|
getAll() {
|
|
return deepCopy(values);
|
|
},
|
|
|
|
set(key, value, {noBroadcast, noSync} = {}) {
|
|
const oldValue = deepCopy(values[key]);
|
|
values[key] = value;
|
|
defineReadonlyProperty(this.readOnlyValues, key, value);
|
|
if (!noBroadcast && !equal(value, oldValue)) {
|
|
this.broadcast(key, value, {noSync});
|
|
}
|
|
localStorage[key] = typeof defaults[key] == 'object'
|
|
? JSON.stringify(value)
|
|
: value;
|
|
},
|
|
|
|
remove: key => this.set(key, undefined),
|
|
|
|
reset: key => this.set(key, deepCopy(defaults[key])),
|
|
|
|
broadcast(key, value, {noSync} = {}) {
|
|
broadcastPrefs[key] = value;
|
|
debounce(doBroadcast);
|
|
if (!noSync) {
|
|
debounce(doSyncSet);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Unlike sync, HTML5 localStorage is ready at browser startup
|
|
// so we'll mirror the prefs to avoid using the wrong defaults
|
|
// during the startup phase
|
|
for (const key in defaults) {
|
|
const defaultValue = defaults[key];
|
|
let value = localStorage[key];
|
|
if (typeof value == 'string') {
|
|
switch (typeof defaultValue) {
|
|
case 'boolean':
|
|
value = value.toLowerCase() === 'true';
|
|
break;
|
|
case 'number':
|
|
value |= 0;
|
|
break;
|
|
case 'object':
|
|
value = tryJSONparse(value) || defaultValue;
|
|
break;
|
|
}
|
|
} else {
|
|
value = defaultValue;
|
|
}
|
|
this.set(key, value, {noBroadcast: true});
|
|
}
|
|
|
|
getSync().get('settings', ({settings: synced}) => {
|
|
if (synced) {
|
|
for (const key in defaults) {
|
|
if (key == 'popupWidth' && synced[key] != values.popupWidth) {
|
|
// this is a fix for the period when popupWidth wasn't synced
|
|
// TODO: remove it in a couple of months before the summer 2017
|
|
continue;
|
|
}
|
|
if (key in synced) {
|
|
this.set(key, synced[key], {noSync: true});
|
|
}
|
|
}
|
|
}
|
|
if (typeof contextMenus !== 'undefined') {
|
|
for (const id in contextMenus) {
|
|
if (typeof values[id] == 'boolean') {
|
|
this.broadcast(id, values[id], {noSync: true});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area == 'sync' && 'settings' in changes) {
|
|
const synced = changes.settings.newValue;
|
|
if (synced) {
|
|
for (const key in defaults) {
|
|
if (key in synced) {
|
|
this.set(key, synced[key], {noSync: true});
|
|
}
|
|
}
|
|
} else {
|
|
// user manually deleted our settings, we'll recreate them
|
|
getSync().set({'settings': values});
|
|
}
|
|
}
|
|
});
|
|
}();
|
|
|
|
|
|
// 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) {
|
|
const 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(msg => {
|
|
if (msg.prefs) {
|
|
for (const prefName in msg.prefs) {
|
|
if (prefName in localIDs) {
|
|
updateElement(prefName, msg.prefs[prefName]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
function updateElement(id, value) {
|
|
const el = document.getElementById(id);
|
|
el[isCheckbox(el) ? 'checked' : 'value'] = value || prefs.get(id);
|
|
el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
|
|
return el;
|
|
}
|
|
}
|
|
|
|
|
|
function getCodeMirrorThemes(callback) {
|
|
chrome.runtime.getPackageDirectoryEntry(function(rootDir) {
|
|
rootDir.getDirectory('codemirror/theme', {create: false}, function(themeDir) {
|
|
themeDir.createReader().readEntries(function(entries) {
|
|
const themes = [chrome.i18n.getMessage('defaultTheme')];
|
|
entries
|
|
.filter(entry => entry.isFile)
|
|
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
|
.forEach(function(entry) {
|
|
themes.push(entry.name.replace(/\.css$/, ''));
|
|
});
|
|
if (callback) {
|
|
callback(themes);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
function sessionStorageHash(name) {
|
|
return {
|
|
name,
|
|
value: runTryCatch(JSON.parse, sessionStorage[name]) || {},
|
|
set(k, v) {
|
|
this.value[k] = v;
|
|
this.updateStorage();
|
|
},
|
|
unset(k) {
|
|
delete this.value[k];
|
|
this.updateStorage();
|
|
},
|
|
updateStorage() {
|
|
sessionStorage[this.name] = JSON.stringify(this.value);
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function deepCopy(obj) {
|
|
if (!obj || typeof obj != 'object') {
|
|
return obj;
|
|
} else {
|
|
const emptyCopy = Object.create(Object.getPrototypeOf(obj));
|
|
return deepMerge(emptyCopy, obj);
|
|
}
|
|
}
|
|
|
|
|
|
function deepMerge(target, ...args) {
|
|
for (const obj of args) {
|
|
for (const k in obj) {
|
|
const value = obj[k];
|
|
if (!value || typeof value != 'object') {
|
|
target[k] = value;
|
|
} else if (k in target) {
|
|
deepMerge(target[k], value);
|
|
} else if (typeof value.slice == 'function') {
|
|
target[k] = value.slice();
|
|
} 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 (const k in a) {
|
|
if (a[k] !== b[k]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function defineReadonlyProperty(obj, key, value) {
|
|
const copy = deepCopy(value);
|
|
if (typeof copy == 'object') {
|
|
Object.freeze(copy);
|
|
}
|
|
Object.defineProperty(obj, key, {value: copy, configurable: true});
|
|
}
|
|
|
|
|
|
// Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494
|
|
function getSync() {
|
|
if ('sync' in chrome.storage) {
|
|
return chrome.storage.sync;
|
|
}
|
|
const crappyStorage = {};
|
|
return {
|
|
get(key, callback) {
|
|
callback(crappyStorage[key] || {});
|
|
},
|
|
set(source, callback) {
|
|
for (const 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 propNames = ['code', 'urlPrefixes', 'urls', 'domains', 'regexps'];
|
|
const typeBcaches = [];
|
|
checkingEveryInA:
|
|
for (const sectionA of styleA.sections) {
|
|
const typeAcache = new Map();
|
|
for (const name of propNames) {
|
|
typeAcache.set(name, getType(sectionA[name]));
|
|
}
|
|
lookingForDupeInB:
|
|
for (let i = 0, sectionB; (sectionB = styleB.sections[i]); i++) {
|
|
const typeBcache = typeBcaches[i] = typeBcaches[i] || new Map();
|
|
comparingProps:
|
|
for (const name of propNames) {
|
|
const propA = sectionA[name];
|
|
const typeA = typeAcache.get(name);
|
|
const propB = sectionB[name];
|
|
let typeB = typeBcache.get(name);
|
|
if (!typeB) {
|
|
typeB = getType(propB);
|
|
typeBcache.set(name, typeB);
|
|
}
|
|
if (typeA != typeB) {
|
|
const bothEmptyOrUndefined =
|
|
(typeA == 'undefined' || (typeA == 'array' && propA.length == 0)) &&
|
|
(typeB == 'undefined' || (typeB == 'array' && propB.length == 0));
|
|
if (bothEmptyOrUndefined) {
|
|
continue comparingProps;
|
|
} else {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
if (typeA == 'undefined') {
|
|
continue comparingProps;
|
|
}
|
|
if (typeA == 'array') {
|
|
if (propA.length != propB.length) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
for (const item of propA) {
|
|
if (propB.indexOf(item) < 0) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
continue comparingProps;
|
|
}
|
|
if (typeA == 'string' && propA != propB) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
// dupe found
|
|
continue checkingEveryInA;
|
|
}
|
|
// dupe not found
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function compileStyleRegExps({style, compileAll}) {
|
|
const t0 = performance.now();
|
|
for (const section of style.sections || []) {
|
|
for (const regexp of section.regexps) {
|
|
for (let pass = 1; pass <= (compileAll ? 2 : 1); pass++) {
|
|
const cacheKey = pass == 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp;
|
|
if (cachedStyles.regexps.has(cacheKey)) {
|
|
continue;
|
|
}
|
|
// according to CSS4 @document specification the entire URL must match
|
|
const anchored = pass == 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
|
|
const rx = tryRegExp(anchored);
|
|
cachedStyles.regexps.set(cacheKey, rx || false);
|
|
if (!compileAll && performance.now() - t0 > 100) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|