dcfb8ad356
+ 1.5x speedup thanks to checkedInB memoization + more strict type comparison + two-way array comparison (more correct, even if there's no practical difference)
501 lines
14 KiB
JavaScript
501 lines
14 KiB
JavaScript
/* global cachedStyles: true */
|
|
'use strict';
|
|
|
|
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';
|
|
|
|
// Note, only 'var'-declared variables are visible from another extension page
|
|
// eslint-disable-next-line no-var
|
|
var cachedStyles = {
|
|
list: null, // array of all styles
|
|
byId: new Map(), // all styles indexed by id
|
|
filters: new Map(), // filterStyles() parameters mapped to the returned results, 10k max
|
|
regexps: new Map(), // compiled style regexps
|
|
urlDomains: new Map(), // getDomain() results for 100 last checked urls
|
|
emptyCode: new Map(), // entire code is comments/whitespace/@namespace
|
|
mutex: {
|
|
inProgress: false, // while getStyles() is reading IndexedDB all subsequent calls
|
|
onDone: [], // to getStyles() are queued and resolved when the first one finishes
|
|
},
|
|
};
|
|
|
|
|
|
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,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
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});
|
|
}
|
|
callback(filterStyles(options));
|
|
|
|
cachedStyles.mutex.inProgress = false;
|
|
for (const {options, callback} of cachedStyles.mutex.onDone) {
|
|
callback(filterStyles(options));
|
|
}
|
|
cachedStyles.mutex.onDone = [];
|
|
};
|
|
}, null);
|
|
}
|
|
|
|
|
|
function filterStyles({
|
|
enabled,
|
|
url = null,
|
|
id = null,
|
|
matchUrl = null,
|
|
asHash = null,
|
|
strictRegexp = true, // used by the popup to detect bad regexps
|
|
} = {}) {
|
|
enabled = fixBoolean(enabled);
|
|
id = id === null ? null : Number(id);
|
|
|
|
if (enabled === null
|
|
&& url === null
|
|
&& id === null
|
|
&& matchUrl === null
|
|
&& asHash != true) {
|
|
return cachedStyles.list;
|
|
}
|
|
const disableAll = asHash && prefs.get('disableAll', false);
|
|
|
|
if (matchUrl && matchUrl.startsWith(URLS.chromeWebStore)) {
|
|
// CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
|
|
// https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
|
|
return asHash ? {} : [];
|
|
}
|
|
|
|
// 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) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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 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;
|
|
}
|
|
|
|
if (id !== null) {
|
|
// Update or create
|
|
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);
|
|
write(Object.assign(oldStyle, style), {existed, codeIsUpdated});
|
|
};
|
|
} else {
|
|
// Create
|
|
delete style.id;
|
|
write(Object.assign({
|
|
// Set optional things if they're undefined
|
|
enabled: true,
|
|
updateUrl: null,
|
|
md5Url: null,
|
|
url: null,
|
|
originalMd5: null,
|
|
}, style));
|
|
}
|
|
|
|
function write(style, {existed, codeIsUpdated} = {}) {
|
|
style.sections = (style.sections || []).map(section =>
|
|
Object.assign({
|
|
urls: [],
|
|
urlPrefixes: [],
|
|
domains: [],
|
|
regexps: [],
|
|
}, section)
|
|
);
|
|
os.put(style).onsuccess = event => {
|
|
style.id = style.id || event.target.result;
|
|
invalidateCache(existed ? {updated: style} : {added: style});
|
|
compileStyleRegExps({style});
|
|
if (notify) {
|
|
notifyAllTabs({
|
|
method: existed ? 'styleUpdated' : 'styleAdded',
|
|
style, codeIsUpdated, reason,
|
|
});
|
|
}
|
|
resolve(style);
|
|
};
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
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({deletedId: id});
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleDeleted', id});
|
|
}
|
|
resolve(id);
|
|
};
|
|
}));
|
|
}
|
|
|
|
|
|
function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) {
|
|
if (!matchUrl.startsWith('http')
|
|
&& !matchUrl.startsWith('ftp')
|
|
&& !matchUrl.startsWith('file')
|
|
&& !matchUrl.startsWith(URLS.ownOrigin)) {
|
|
return [];
|
|
}
|
|
const sections = [];
|
|
for (const section of style.sections) {
|
|
const {urls, domains, urlPrefixes, regexps, code} = section;
|
|
if ((!urls.length && !urlPrefixes.length && !domains.length && !regexps.length
|
|
|| urls.length && urls.indexOf(matchUrl) >= 0
|
|
|| urlPrefixes.length && arraySomeIsPrefix(urlPrefixes, matchUrl)
|
|
|| domains.length && arraySomeIn(cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl), domains)
|
|
|| regexps.length && arraySomeMatches(regexps, matchUrl, strictRegexp)
|
|
) && !styleCodeEmpty(code)) {
|
|
sections.push(section);
|
|
if (stopOnFirst) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return sections;
|
|
|
|
function arraySomeIsPrefix(array, string) {
|
|
for (const prefix of array) {
|
|
if (string.startsWith(prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function arraySomeIn(array, haystack) {
|
|
for (const el of array) {
|
|
if (haystack.indexOf(el) >= 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function arraySomeMatches(array, matchUrl, strictRegexp) {
|
|
for (const regexp of array) {
|
|
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)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
function styleCodeEmpty(code) {
|
|
// 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
|
|
let isEmpty = code !== null &&
|
|
code.length < 1000 &&
|
|
cachedStyles.emptyCode.get(code);
|
|
if (isEmpty !== undefined) {
|
|
return isEmpty;
|
|
}
|
|
isEmpty = !code || !code.trim()
|
|
|| code.indexOf('@namespace') >= 0
|
|
&& code.replace(RX_CSS_COMMENTS, '').replace(RX_NAMESPACE, '').trim() == '';
|
|
cachedStyles.emptyCode.set(code, isEmpty);
|
|
return isEmpty;
|
|
}
|
|
|
|
|
|
function styleSectionsEqual({sections: a}, {sections: b}) {
|
|
if (!a || !b) {
|
|
return undefined;
|
|
}
|
|
if (a.length != b.length) {
|
|
return false;
|
|
}
|
|
const checkedInB = [];
|
|
return a.every(sectionA => b.some(sectionB => {
|
|
if (!checkedInB.includes(sectionB) && propertiesEqual(sectionA, sectionB)) {
|
|
checkedInB.push(sectionB);
|
|
return true;
|
|
}
|
|
}));
|
|
|
|
function propertiesEqual(secA, secB) {
|
|
for (const name of ['urlPrefixes', 'urls', 'domains', 'regexps']) {
|
|
if (!equalOrEmpty(secA[name], secB[name], 'every', arrayMirrors)) {
|
|
return false;
|
|
}
|
|
}
|
|
return equalOrEmpty(secA.code, secB.code, 'substr', (a, b) => a == b);
|
|
}
|
|
|
|
function equalOrEmpty(a, b, telltale, comparator) {
|
|
const typeA = a && typeof a[telltale] == 'function';
|
|
const typeB = b && typeof b[telltale] == 'function';
|
|
return (
|
|
(a === null || a === undefined || (typeA && !a.length)) &&
|
|
(b === null || b === undefined || (typeB && !b.length))
|
|
) || typeA && typeB && a.length == b.length && comparator(a, b);
|
|
}
|
|
|
|
function arrayMirrors(array1, array2) {
|
|
for (const el of array1) {
|
|
if (array2.indexOf(el) < 0) {
|
|
return false;
|
|
}
|
|
}
|
|
for (const el of array2) {
|
|
if (array1.indexOf(el) < 0) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function invalidateCache({added, updated, deletedId} = {}) {
|
|
// prevent double-add on echoed invalidation
|
|
const cached = added && cachedStyles.byId.get(added.id);
|
|
if (cached) {
|
|
return;
|
|
}
|
|
if (!cachedStyles.list) {
|
|
return;
|
|
}
|
|
if (updated) {
|
|
const cached = cachedStyles.byId.get(updated.id);
|
|
if (cached) {
|
|
Object.assign(cached, updated);
|
|
}
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
if (added) {
|
|
cachedStyles.list.push(added);
|
|
cachedStyles.byId.set(added.id, 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);
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
}
|
|
cachedStyles.list = null;
|
|
cachedStyles.filters.clear();
|
|
}
|
|
|
|
|
|
function cleanupCachedFilters({force = false} = {}) {
|
|
if (!force) {
|
|
debounce(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));
|
|
}
|
|
|
|
|
|
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;
|
|
}
|