stylus/background/style-manager.js

474 lines
12 KiB
JavaScript
Raw Normal View History

2018-10-05 10:47:52 +00:00
/* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */
2018-10-11 12:00:25 +00:00
/* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty
2018-10-08 12:12:39 +00:00
getStyleWithNoCode msg */
2018-10-10 16:54:38 +00:00
/* exported styleManager */
2018-10-04 04:46:19 +00:00
'use strict';
2018-10-08 12:12:39 +00:00
/*
This style manager is a layer between content script and the DB. When a style
is added/updated, it broadcast a message to content script and the content
script would try to fetch the new code.
The live preview feature relies on `runtime.connect` and `port.onDisconnect`
to cleanup the temporary code. See /edit/live-preview.js.
*/
2018-10-03 19:35:07 +00:00
const styleManager = (() => {
const preparing = prepare();
2018-10-04 04:46:19 +00:00
const styles = new Map();
2018-10-03 19:35:07 +00:00
const cachedStyleForUrl = createCache();
const BAD_MATCHER = {test: () => false};
const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildGlob);
2018-10-03 19:35:07 +00:00
2018-10-08 12:12:39 +00:00
handleLivePreviewConnections();
2018-10-08 09:49:57 +00:00
2018-10-03 19:35:07 +00:00
return ensurePrepared({
2018-10-07 15:28:41 +00:00
get,
2018-10-04 17:03:40 +00:00
getStylesInfo,
getSectionsByUrl,
2018-10-03 19:35:07 +00:00
installStyle,
deleteStyle,
setStyleExclusions,
2018-10-04 17:03:40 +00:00
editSave,
2018-10-05 10:47:52 +00:00
toggleStyle,
getAllStyles, // used by import-export
2018-10-06 05:45:37 +00:00
getStylesInfoByUrl, // used by popup
2018-10-11 19:43:31 +00:00
styleExists,
2018-10-03 19:35:07 +00:00
});
2018-10-08 12:12:39 +00:00
function handleLivePreviewConnections() {
2018-10-09 15:38:29 +00:00
chrome.runtime.onConnect.addListener(port => {
2018-10-08 12:12:39 +00:00
if (port.name !== 'livePreview') {
return;
}
let id;
port.onMessage.addListener(data => {
if (!id) {
id = data.id;
}
const style = styles.get(id);
style.preview = data;
broadcastStyleUpdated(style.preview, 'editPreview');
});
port.onDisconnect.addListener(() => {
port = null;
if (id) {
const style = styles.get(id);
style.preview = null;
broadcastStyleUpdated(style.data, 'editPreview');
}
});
});
}
2018-10-07 15:28:41 +00:00
function get(id) {
return styles.get(id).data;
}
2018-10-05 10:47:52 +00:00
function getAllStyles() {
return [...styles.values()].map(s => s.data);
}
2018-10-04 17:03:40 +00:00
function toggleStyle(id, enabled) {
const style = styles.get(id);
2018-10-06 17:42:43 +00:00
const data = Object.assign({}, style.data, {enabled});
return saveStyle(data)
2018-10-05 10:47:52 +00:00
.then(newData => {
style.data = newData;
2018-10-06 09:47:43 +00:00
for (const url of style.appliesTo) {
const cache = cachedStyleForUrl.get(url);
if (cache) {
2018-10-06 17:42:43 +00:00
cache.sections[newData.id].enabled = newData.enabled;
2018-10-06 09:47:43 +00:00
}
}
2018-10-06 05:02:45 +00:00
const message = {
2018-10-05 10:47:52 +00:00
method: 'styleUpdated',
2018-10-06 17:42:43 +00:00
reason: 'toggle',
2018-10-05 10:47:52 +00:00
codeIsUpdated: false,
style: {id, enabled}
2018-10-06 05:02:45 +00:00
};
if ([...style.appliesTo].every(isExtensionUrl)) {
2018-10-06 09:47:43 +00:00
return msg.broadcastExtension(message, 'both');
2018-10-06 05:02:45 +00:00
}
2018-10-06 17:42:43 +00:00
// FIXME: this won't work with iframes
// return msg.broadcast(message, tab => style.appliesTo.has(tab.url));
return msg.broadcast(message);
2018-10-05 10:47:52 +00:00
})
.then(() => id);
2018-10-04 17:03:40 +00:00
}
2018-10-05 10:47:52 +00:00
function isExtensionUrl(url) {
return /^\w+?-extension:\/\//.test(url);
}
function getStylesInfo(filter) {
if (filter && filter.id) {
return [getStyleWithNoCode(styles.get(filter.id).data)];
}
return [...styles.values()]
2018-10-06 17:42:43 +00:00
.filter(s => !filter || filterMatch(filter, s.data))
2018-10-05 10:47:52 +00:00
.map(s => getStyleWithNoCode(s.data));
}
2018-10-11 19:43:31 +00:00
function styleExists(filter) {
return [...styles.value()].some(s => filterMatch(filter, s.data));
2018-10-05 13:28:19 +00:00
}
2018-10-06 17:42:43 +00:00
function filterMatch(filter, target) {
2018-10-05 10:47:52 +00:00
for (const key of Object.keys(filter)) {
2018-10-06 17:42:43 +00:00
if (filter[key] !== target[key]) {
2018-10-05 10:47:52 +00:00
return false;
}
}
return true;
2018-10-04 17:03:40 +00:00
}
2018-10-06 17:42:43 +00:00
function installStyle(data) {
const style = styles.get(data.id);
if (!style) {
data = Object.assign(createNewStyle(), data);
} else {
data = Object.assign({}, style.data, data);
}
// FIXME: update installDate?
return calcStyleDigest(data)
.then(digest => {
data.originalDigest = digest;
return saveStyle(data);
})
2018-10-08 09:49:57 +00:00
.then(newData => handleSave(
newData,
style ? 'update' : 'install',
style ? 'styleUpdated' : 'styleAdded'
));
2018-10-06 17:42:43 +00:00
}
2018-10-06 05:45:37 +00:00
function editSave(data) {
2018-10-06 17:42:43 +00:00
const style = styles.get(data.id);
if (style) {
data = Object.assign({}, style.data, data);
} else {
data = Object.assign(createNewStyle(), data);
}
2018-10-06 05:45:37 +00:00
return saveStyle(data)
2018-10-08 09:49:57 +00:00
.then(newData => handleSave(
newData,
'editSave',
style ? 'styleUpdated' : 'styleAdded'
));
2018-10-05 13:28:19 +00:00
}
2018-10-03 19:35:07 +00:00
2018-10-06 05:45:37 +00:00
function setStyleExclusions(id, exclusions) {
2018-10-10 15:05:20 +00:00
const data = Object.assign({}, styles.get(id).data, {exclusions});
2018-10-06 05:45:37 +00:00
return saveStyle(data)
2018-10-08 09:49:57 +00:00
.then(newData => handleSave(newData, 'exclusions', 'styleUpdated'));
2018-10-06 05:45:37 +00:00
}
2018-10-03 19:35:07 +00:00
function deleteStyle(id) {
2018-10-05 10:47:52 +00:00
const style = styles.get(id);
2018-10-03 19:35:07 +00:00
return db.exec('delete', id)
.then(() => {
2018-10-05 10:47:52 +00:00
for (const url of style.appliesTo) {
const cache = cachedStyleForUrl.get(url);
2018-10-06 07:11:01 +00:00
if (cache) {
2018-10-06 17:42:43 +00:00
delete cache.sections[id];
2018-10-06 07:11:01 +00:00
}
2018-10-05 10:47:52 +00:00
}
styles.delete(id);
2018-10-06 05:02:45 +00:00
return msg.broadcast({
2018-10-05 10:47:52 +00:00
method: 'styleDeleted',
2018-10-06 05:02:45 +00:00
style: {id}
2018-10-06 17:42:43 +00:00
});
2018-10-05 10:47:52 +00:00
})
.then(() => id);
2018-10-03 19:35:07 +00:00
}
2018-10-06 17:42:43 +00:00
function ensurePrepared(methods) {
2018-10-07 13:20:39 +00:00
const prepared = {};
for (const [name, fn] of Object.entries(methods)) {
prepared[name] = (...args) =>
2018-10-06 17:42:43 +00:00
preparing.then(() => fn(...args));
}
2018-10-07 13:20:39 +00:00
return prepared;
2018-10-06 17:42:43 +00:00
}
2018-10-05 13:28:19 +00:00
function createNewStyle() {
return {
enabled: true,
updateUrl: null,
md5Url: null,
url: null,
originalMd5: null,
installDate: Date.now()
};
}
2018-10-08 09:49:57 +00:00
function broadcastStyleUpdated(data, reason, method = 'styleUpdated') {
2018-10-06 05:27:58 +00:00
const style = styles.get(data.id);
2018-10-06 05:45:37 +00:00
const excluded = new Set();
2018-10-06 17:42:43 +00:00
const updated = new Set();
for (const [url, cache] of cachedStyleForUrl.entries()) {
if (!style.appliesTo.has(url)) {
cache.maybeMatch.add(data.id);
continue;
}
const code = getAppliedCode(url, data);
2018-10-06 05:45:37 +00:00
if (!code) {
excluded.add(url);
2018-10-06 17:42:43 +00:00
delete cache.sections[data.id];
2018-10-06 05:45:37 +00:00
} else {
2018-10-06 17:42:43 +00:00
updated.add(url);
cache.sections[data.id] = {
id: data.id,
enabled: data.enabled,
code
};
2018-10-06 05:27:58 +00:00
}
}
2018-10-06 17:42:43 +00:00
style.appliesTo = updated;
return msg.broadcast({
2018-10-08 09:49:57 +00:00
method,
2018-10-06 05:02:45 +00:00
style: {
id: data.id,
2018-10-06 17:42:43 +00:00
enabled: data.enabled
},
reason
});
2018-10-06 05:02:45 +00:00
}
2018-10-06 17:42:43 +00:00
// function importStyle(style) {
2018-10-03 19:35:07 +00:00
// FIXME: move this to importer
// style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
// delete style.styleDigest; // TODO: remove in the future
// if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
// delete style.originalDigest;
// }
2018-10-06 17:42:43 +00:00
// }
2018-10-03 19:35:07 +00:00
function saveStyle(style) {
2018-10-06 05:02:45 +00:00
if (!style.name) {
throw new Error('style name is empty');
}
2018-10-10 15:05:20 +00:00
if (style.id == null) {
delete style.id;
}
2018-10-06 05:27:58 +00:00
return db.exec('put', style)
2018-10-03 19:35:07 +00:00
.then(event => {
if (style.id == null) {
style.id = event.target.result;
}
return style;
});
}
2018-10-08 09:49:57 +00:00
function handleSave(data, reason, method) {
const style = styles.get(data.id);
if (!style) {
styles.set(data.id, {
appliesTo: new Set(),
data
});
} else {
style.data = data;
}
return broadcastStyleUpdated(data, reason, method)
.then(() => data);
}
2018-10-06 05:45:37 +00:00
function getStylesInfoByUrl(url) {
// FIXME: do we want to cache this? Who would like to rapidly using popup
// or searching the DB with the same URL?
const result = [];
for (const style of styles.values()) {
let excluded = false;
let sloppy = false;
let sectionMatched = false;
const match = urlMatchStyle(url, style.data);
if (match === false) {
continue;
}
if (match === 'excluded') {
excluded = true;
}
for (const section of style.data.sections) {
if (styleCodeEmpty(section.code)) {
continue;
}
const match = urlMatchSection(url, section);
if (match) {
if (match === 'sloppy') {
sloppy = true;
}
sectionMatched = true;
break;
}
}
if (sectionMatched) {
result.push({
data: getStyleWithNoCode(style.data),
excluded,
sloppy
});
}
}
return result;
2018-10-05 10:47:52 +00:00
}
2018-10-06 09:47:43 +00:00
function getSectionsByUrl(url, filter) {
2018-10-06 07:11:01 +00:00
let cache = cachedStyleForUrl.get(url);
if (!cache) {
2018-10-06 17:42:43 +00:00
cache = {
sections: {},
maybeMatch: new Set()
};
buildCache(styles.values());
cachedStyleForUrl.set(url, cache);
} else if (cache.maybeMatch.size) {
buildCache(
[...cache.maybeMatch]
.filter(i => styles.has(i))
.map(i => styles.get(i))
);
}
// if (filter && filter.id) {
// if (!cache.sections[filter.id]) {
// return {};
// }
// return {[filter.id]: cache.sections[filter.id]};
// }
if (filter) {
return Object.values(cache.sections)
.filter(s => filterMatch(filter, s))
.reduce((o, v) => {
o[v.id] = v;
return o;
}, {});
}
return cache.sections;
function buildCache(styleList) {
2018-10-08 09:49:57 +00:00
for (const {appliesTo, data, preview} of styleList) {
const code = getAppliedCode(url, preview || data);
2018-10-05 10:47:52 +00:00
if (code) {
2018-10-06 17:42:43 +00:00
cache.sections[data.id] = {
2018-10-06 07:11:01 +00:00
id: data.id,
enabled: data.enabled,
2018-10-06 07:22:04 +00:00
code
2018-10-06 07:11:01 +00:00
};
2018-10-07 15:41:46 +00:00
// FIXME: memory leak
2018-10-05 10:47:52 +00:00
appliesTo.add(url);
2018-10-03 19:35:07 +00:00
}
}
}
}
2018-10-10 17:22:13 +00:00
// TODO: report excluded styles and sloppy regexps?
2018-10-05 13:28:19 +00:00
function getAppliedCode(url, data) {
if (urlMatchStyle(url, data) !== true) {
2018-10-05 13:28:19 +00:00
return;
}
let code = '';
for (const section of data.sections) {
if (urlMatchSection(url, section) === true && !styleCodeEmpty(section.code)) {
code += section.code;
2018-10-05 13:28:19 +00:00
}
}
2018-10-06 07:40:07 +00:00
return code;
2018-10-06 07:33:18 +00:00
}
2018-10-03 19:35:07 +00:00
function prepare() {
return db.exec('getAll').then(event => {
2018-10-05 10:47:52 +00:00
const styleList = event.target.result;
if (!styleList) {
return;
}
2018-10-03 19:35:07 +00:00
for (const style of styleList) {
2018-10-05 10:47:52 +00:00
styles.set(style.id, {
appliesTo: new Set(),
data: style
});
2018-10-03 19:35:07 +00:00
if (!style.name) {
style.name = 'ID: ' + style.id;
}
}
});
}
function urlMatchStyle(url, style) {
2018-10-04 04:46:19 +00:00
if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url))) {
return 'excluded';
2018-10-03 19:35:07 +00:00
}
return true;
}
function urlMatchSection(url, section) {
2018-10-11 15:54:58 +00:00
const domain = getDomain(url);
if (section.domains && section.domains.some(d => d === domain || domain.endsWith(`.${d}`))) {
2018-10-03 19:35:07 +00:00
return true;
}
if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) {
return true;
}
2018-10-10 17:22:13 +00:00
// as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it for `url()` function
if (section.urls && (
section.urls.includes(url) ||
section.urls.includes(getUrlNoHash(url))
)) {
2018-10-03 19:35:07 +00:00
return true;
}
if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) {
return true;
}
if (section.regexps && section.regexps.some(r => compileSloppyRe(r).test(url))) {
return 'sloppy';
}
2018-10-06 05:48:46 +00:00
if (
(!section.regexps || !section.regexps.length) &&
(!section.urlPrefixes || !section.urlPrefixes.length) &&
(!section.urls || !section.urls.length) &&
(!section.domains || !section.domains.length)
2018-10-06 07:11:01 +00:00
) {
return true;
}
2018-10-03 19:35:07 +00:00
return false;
}
function createCompiler(compile) {
const cache = createCache();
return text => {
let re = cache.get(text);
2018-10-03 19:35:07 +00:00
if (!re) {
re = tryRegExp(compile(text));
if (!re) {
re = BAD_MATCHER;
}
cache.set(text, re);
2018-10-03 19:35:07 +00:00
}
return re;
};
2018-10-03 19:35:07 +00:00
}
function buildGlob(text) {
const prefix = text[0] === '^' ? '' : '\\b';
const suffix = text[text.length - 1] === '$' ? '' : '\\b';
return `${prefix}${escape(text)}${suffix}`;
function escape(text) {
// FIXME: using .* everywhere is slow
return text.replace(/[.*]/g, m => m === '.' ? '\\.' : '.*');
}
}
function getDomain(url) {
2018-10-11 11:29:17 +00:00
return url.match(/^[\w-]+:\/+(?:[\w:-]+@)?([^:/#]+)/)[1];
2018-10-03 19:35:07 +00:00
}
function getUrlNoHash(url) {
return url.split('#')[0];
}
})();