diff --git a/.eslintrc b/.eslintrc index 85e21a98..d02a8b8c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ globals: # messaging.js OWN_ORIGIN: false KEEP_CHANNEL_OPEN: false + RX_SUPPORTED_URLS: false configureCommands: false notifyAllTabs: false refreshAllTabs: false diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9b462ee4..46eb7ae4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -402,6 +402,20 @@ "message": "Invalid regexps skipped", "description": "RegExp test report: label for the invalid expressions" }, + "styleRegexpPartialExplanation": { + "message": "This style uses partially matching regexps in violation of CSS4 @document specification which requires a full URL match. The affected CSS sections were not applied to the page. This style was probably created in Stylish-for-Chrome which incorrectly checks 'regexp()' rules since the very first version (known bug)." + }, + "styleRegexpInvalidExplanation": { + "message": "Some 'regexp()' rules that could not be compiled at all." + }, + "styleNotAppliedRegexpProblemTooltip": { + "message": "Style was not applied due to its incorrect usage of 'regexp()'", + "description": "Tooltip in the popup for styles that were not applied at all" + }, + "styleRegexpProblemTooltip": { + "message": "Number of sections not applied due to incorrect usage of 'regexp()'", + "description": "Tooltip in the popup for styles that were applied only partially" + }, "styleBeautify": { "message": "Beautify", "description": "Label for the CSS-beautifier button on the edit style page" diff --git a/backup/fileSaveLoad.js b/backup/fileSaveLoad.js index 3bf03510..114d8763 100644 --- a/backup/fileSaveLoad.js +++ b/backup/fileSaveLoad.js @@ -70,7 +70,7 @@ function importFromString(jsonString) { continue; } item.name = item.name.trim(); - const byId = (cachedStyles.byId.get(item.id) || {}).style; + const byId = cachedStyles.byId.get(item.id); const byName = oldStylesByName.get(item.name); const oldStyle = byId && byId.name.trim() == item.name || !byName ? byId : byName; if (oldStyle == byName && byName) { diff --git a/manage.js b/manage.js index a29855d0..d7094034 100644 --- a/manage.js +++ b/manage.js @@ -7,7 +7,7 @@ const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps']; const TARGET_LIMIT = 10; -getStylesSafe({code: false}) +getStylesSafe() .then(showStyles) .then(initGlobalEvents); @@ -235,7 +235,7 @@ class EntryOnClick { static delete(event) { const styleElement = getClickedStyleElement(event); const id = styleElement.styleId; - const name = ((cachedStyles.byId.get(id) || {}).style || {}).name; + const {name} = cachedStyles.byId.get(id) || {}; animateElement(styleElement, {className: 'highlight'}); messageBox({ title: t('deleteStyleConfirm'), @@ -436,7 +436,7 @@ function searchStyles({immediately, container}) { } for (const element of (container || installed).children) { - const {style} = cachedStyles.byId.get(element.styleId) || {}; + const style = cachedStyles.byId.get(element.styleId) || {}; if (style) { const isMatching = !query || isMatchingText(style.name) diff --git a/messaging.js b/messaging.js index fb221764..d2e8ecba 100644 --- a/messaging.js +++ b/messaging.js @@ -4,6 +4,7 @@ // keep message channel open for sendResponse in chrome.runtime.onMessage listener const KEEP_CHANNEL_OPEN = true; const OWN_ORIGIN = chrome.runtime.getURL(''); +const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${OWN_ORIGIN}`); function notifyAllTabs(request) { diff --git a/popup.css b/popup.css index bcd7c7d6..a92052de 100644 --- a/popup.css +++ b/popup.css @@ -1,29 +1,43 @@ body { width: 252px; font-size: 12px; - font-family: Arial,"Helvetica Neue",Helvetica,sans-serif; + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + margin: 0; } + +body > div:not(#installed) { + margin-left: 0.75em; + margin-right: 0.75em; +} + input[type=checkbox] { outline: none; } + #disable-all-wrapper { padding: 0.3em 0 0.6em; } + #no-styles { font-style: italic; } + #popup-shortcuts-button { margin-left: 3px; } + .checker { display: inline; } + .style-name { cursor: default; font-weight: bold; display: block; } -a, a:visited { + +a, +a:visited { color: black; text-decoration-skip: ink; } @@ -33,53 +47,151 @@ a, a:visited { width: 16px; vertical-align: top; } + .left-gutter input { margin-bottom: 1px; margin-top: 0; margin-left: 0; } + .main-controls { display: table-cell; } -.entry { - padding: 0.5em 0; -} -.entry:first-child { - padding-top: 0; -} - #unavailable, #installed { border-bottom: 1px solid black; padding-bottom: 2px; } + body > DIV:last-of-type, body.blocked > DIV { border-bottom: none; } + #installed { padding-top: 2px; max-height: 434px; overflow-y: auto; } + #installed.disabled .style-name { text-decoration: line-through; } + #installed .actions { cursor: default; } + #installed .actions a { cursor: pointer; text-decoration: none; } -#installed .style-edit-link { + +/* entry */ + +.entry { + display: flex; + align-items: center; + padding: 5px 0.75em; +} + +.entry:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +.entry .style-edit-link { margin-right: 2px; } -#installed .style-edit-link, #installed .delete { + +.entry .style-edit-link, +.entry .delete { display: inline-block; padding: 0 1px 0; } + +.entry .main-controls { + display: flex; + flex: 1; + width: calc(100% - 20px); + align-items: center; +} + +.entry .main-controls label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 5px; +} + +.not-applied .checker, +.not-applied .style-name, +.not-applied .actions > * { + opacity: .2; + transition: opacity .5s ease-in-out .25s, color .5s ease-in-out .25s; +} + +.not-applied:hover .checker, +.not-applied:hover .style-name, +.not-applied:hover .actions > * { + opacity: 1; +} + +.not-applied:hover .style-name { + color: darkorange; +} + +.regexp-problem-indicator { + background-color: darkorange; + width: 15px; + height: 15px; + line-height: 16px; + border-radius: 8px; + margin-right: 4px; + margin-left: 4px; + text-align: center; + color: white; + font-weight: bold; + box-sizing: border-box; + cursor: pointer; +} + +.regexp-partial .actions, +.regexp-invalid .actions { + order: 2; +} + +#regexp-explanation { + position: fixed; + background-color: white; + left: 0; + right: 0; + padding: .5rem; + font-size: 90%; + border-top: 2px solid black; + border-bottom: 2px solid black; + box-shadow: 0 0 100px black; + display: flex; + flex-direction: column; +} + +#regexp-explanation > div { + display: none; + list-style-type: none; + padding: 0; + margin: 0; +} + +.regexp-partial #regexp-partial, +.regexp-invalid #regexp-invalid { + display: block; +} + +#regexp-explanation > div:not(:last-child) { + margin-bottom: .5rem; +} + .svg-icon { pointer-events: none; transition: fill .5s; @@ -92,20 +204,27 @@ body > .actions { margin-top: 0.5em; } -.actions > div:not(:last-child):not(#disable-all-wrapper), .actions > .main-controls > div:not(:last-child), #unavailable:not(:last-child), #unavailable + .actions { +.actions > div:not(:last-child):not(#disable-all-wrapper), +.actions > .main-controls > div:not(:last-child), +#unavailable:not(:last-child), +#unavailable + .actions { margin-bottom: 0.75em; } -.actions input, .actions label { + +.actions input, +.actions label { vertical-align: middle; } #unavailable { border: none; - display: none; /* flex */ + display: none; + margin-top: 0.75em; align-items: center; justify-content: center; font-size: 14px; } + body.blocked #installed, body.blocked #find-styles, body.blocked #write-style, @@ -118,41 +237,84 @@ body.blocked #unavailable { } /* Never shown, but can be enabled with a style */ -.enable, .disable { + +.enable, +.disable { display: none; } /* 'New style' links */ -#write-style-for {margin-right: .6ex} -.write-style-link {margin-left: .6ex} -.write-style-link::before, .write-style-link::after {font-size: 12px} -.write-style-link::before {content: "\00ad"} /* "soft" hyphen */ -#match {overflow-wrap: break-word;} + +#write-style-for { + margin-right: .6ex +} + +.write-style-link { + margin-left: .6ex +} + +.write-style-link::before, +.write-style-link::after { + font-size: 12px +} + +.write-style-link::before { + content: "\00ad"; /* "soft" hyphen */ +} + +#match { + overflow-wrap: break-word; + display: inline-block; +} /* "breadcrumbs" 'new style' links */ -.breadcrumbs > .write-style-link {margin-left: 0} -.breadcrumbs:hover a {color: #bbb; text-decoration: none} +.breadcrumbs > .write-style-link { + margin-left: 0 +} + +.breadcrumbs:hover a { + color: #bbb; + text-decoration: none +} + +/* use just the subdomain name instead of the full domain name */ +.breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2)) { + font-size: 0 +} - /* use just the subdomain name instead of the full domain name */ -.breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2)) {font-size: 0} .breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2))::before { content: attr(subdomain); } - /* "dot" after each subdomain name */ -.breadcrumbs > .write-style-link[subdomain]::after {content: "."} - /* no "dot" after top-level domain */ -.breadcrumbs > .write-style-link:nth-last-child(2)::after {content: none} - /* "forward slash" before path ("this URL") */ -.breadcrumbs > .write-style-link:last-child::before {content: "\200b/"} +/* "dot" after each subdomain name */ +.breadcrumbs > .write-style-link[subdomain]::after { + content: "." +} + +/* no "dot" after top-level domain */ +.breadcrumbs > .write-style-link:nth-last-child(2)::after { + content: none +} + +/* "forward slash" before path ("this URL") */ +.breadcrumbs > .write-style-link:last-child::before { + content: "\200b/" +} + .breadcrumbs > .write-style-link:last-child:first-child::before, -.breadcrumbs > .write-style-link[subdomain=""] + .write-style-link::before {content: none} +.breadcrumbs > .write-style-link[subdomain=""] + .write-style-link::before { + content: none +} - /* suppress TLD-only link */ -.breadcrumbs > .write-style-link[subdomain=""] {display: none} +/* suppress TLD-only link */ +.breadcrumbs > .write-style-link[subdomain=""] { + display: none +} - /* :hover style */ -.breadcrumbs.url\(\) > .write-style-link, /* :hover or :focus on "this URL" sets class="url()" */ +/* :hover style */ +.breadcrumbs.url\(\) > .write-style-link, + +/* :hover or :focus on "this URL" sets class="url()" */ .breadcrumbs > .write-style-link:hover, .breadcrumbs > .write-style-link:focus, .breadcrumbs > .write-style-link:hover ~ .write-style-link[subdomain], @@ -162,13 +324,15 @@ body.blocked #unavailable { text-decoration-skip: ink; } - /* action buttons */ +/* action buttons */ + #popup-options { display: flex; flex-direction: row; justify-content: space-around; padding: 1.2em 0; } + #popup-options button { margin: 0 2px; width: 33%; @@ -177,72 +341,43 @@ body.blocked #unavailable { text-overflow: ellipsis; } - /* margins */ -body { - margin: 0; -} -body>div:not(#installed) { - margin-left:0.75em; - margin-right:0.75em; -} -#unavailable { - margin-top: 0.75em; -} -#installed .entry { -} - /* entries */ -#installed .entry { - display: flex; - align-items: center; - padding: 5px 0.75em; -} -#installed .entry:nth-child(even) { - background-color: rgba(0, 0, 0, 0.05); -} -#installed .main-controls { - display: flex; - flex: 1; - width: calc(100% - 20px); - align-items: center; -} -#installed .main-controls label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-right: 5px; -} - /* confirm */ +/* confirm */ + #confirm, -#confirm>div>span { +#confirm > div > span { align-items: center; justify-content: center; } + #confirm { z-index: 2147483647; - display: none; /* flex */ + display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; - margin: 0!important; + margin: 0 !important; box-sizing: border-box; background-color: rgba(0, 0, 0, 0.4); animation: lights-off .5s cubic-bezier(.03, .67, .08, .94); animation-fill-mode: both; } + #confirm.lights-on { animation: lights-on .25s ease-in-out; animation-fill-mode: both; } -#confirm.lights-on > div{ + +#confirm.lights-on > div { display: none; } + #confirm[data-display=true] { display: flex; } -#confirm>div { + +#confirm > div { width: 80%; height: 100px; max-height: 80%; @@ -252,25 +387,30 @@ body>div:not(#installed) { flex-direction: column; border: solid 2px rgba(0, 0, 0, 0.5); } -#confirm>div>span { + +#confirm > div > span { display: flex; flex: 1; padding: 0 10px; } -#confirm>div>b { + +#confirm > div > b { padding: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#confirm>div>div { + +#confirm > div > div { padding: 10px; text-align: center; } -.non-windows #confirm>div>div { + +.non-windows #confirm > div > div { direction: rtl; text-align: right; } + @keyframes lights-off { from { background-color: transparent; @@ -279,6 +419,7 @@ body>div:not(#installed) { background-color: rgba(0, 0, 0, 0.4); } } + @keyframes lights-on { from { background-color: rgba(0, 0, 0, 0.4); @@ -287,4 +428,3 @@ body>div:not(#installed) { background-color: transparent; } } - diff --git a/popup.html b/popup.html index 5fb3f9b6..0a36c1ad 100644 --- a/popup.html +++ b/popup.html @@ -37,7 +37,19 @@
- + + + + + @@ -81,7 +93,7 @@
-
+
diff --git a/popup.js b/popup.js index 23f6a30f..553d3ab3 100644 --- a/popup.js +++ b/popup.js @@ -1,15 +1,19 @@ +/* global SLOPPY_REGEXP_PREFIX, compileStyleRegExps */ 'use strict'; let installed; +let tabURL; getActiveTabRealURL().then(url => { - const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${OWN_ORIGIN}`); - const isUrlSupported = RX_SUPPORTED_URLS.test(url); + tabURL = RX_SUPPORTED_URLS.test(url) ? url : ''; Promise.all([ - isUrlSupported ? getStylesSafe({matchUrl: url}) : null, - onDOMready().then(() => initPopup(isUrlSupported ? url : '')), - ]) - .then(([styles]) => styles && showStyles(styles)); + tabURL && getStylesSafe({matchUrl: tabURL}), + onDOMready().then(() => { + initPopup(tabURL); + }), + ]).then(([styles]) => { + showStyles(styles); + }); }); @@ -116,30 +120,55 @@ function initPopup(url) { function showStyles(styles) { + if (!styles) { + return; + } if (!styles.length) { installed.innerHTML = template.noStyles.outerHTML; - } else { - const enabledFirst = prefs.get('popup.enabledFirst'); - styles.sort((a, b) => ( - enabledFirst && a.enabled !== b.enabled - ? !(a.enabled < b.enabled) ? -1 : 1 - : a.name.localeCompare(b.name) - )); - const fragment = document.createDocumentFragment(); - for (const style of styles) { - fragment.appendChild(createStyleElement(style)); - } - installed.appendChild(fragment); + return; } + + const enabledFirst = prefs.get('popup.enabledFirst'); + styles.sort((a, b) => ( + enabledFirst && a.enabled !== b.enabled + ? !(a.enabled < b.enabled) ? -1 : 1 + : a.name.localeCompare(b.name) + )); + + let postponeDetect = false; + const t0 = performance.now(); + const container = document.createDocumentFragment(); + for (const style of styles) { + createStyleElement({style, container, postponeDetect}); + postponeDetect = postponeDetect || performance.now() - t0 > 100; + } + installed.appendChild(container); + + getStylesSafe({matchUrl: tabURL, strictRegexp: false}) + .then(unscreenedStyles => { + for (const unscreened of unscreenedStyles) { + if (!styles.includes(unscreened)) { + postponeDetect = postponeDetect || performance.now() - t0 > 100; + createStyleElement({ + style: Object.assign({appliedSections: [], postponeDetect}, unscreened), + }); + } + } + }); } // silence the inapplicable warning for async code /* eslint no-use-before-define: [2, {"functions": false, "classes": false}] */ -function createStyleElement(style) { +function createStyleElement({ + style, + container = installed, + postponeDetect, +}) { const entry = template.style.cloneNode(true); entry.setAttribute('style-id', style.id); Object.assign(entry, { + id: 'style-' + style.id, styleId: style.id, className: entry.className + ' ' + (style.enabled ? 'enabled' : 'disabled'), onmousedown: openEditorOnMiddleclick, @@ -171,7 +200,18 @@ function createStyleElement(style) { $('.disable', entry).onclick = EntryOnClick.toggle; $('.delete', entry).onclick = EntryOnClick.delete; - return entry; + if (postponeDetect) { + setTimeout(detectSloppyRegexps, 0, {entry, style}); + } else { + detectSloppyRegexps({entry, style}); + } + + const oldElement = $('#style-' + style.id); + if (oldElement) { + oldElement.parentNode.replaceChild(entry, oldElement); + } else { + container.appendChild(entry); + } } @@ -194,7 +234,7 @@ class EntryOnClick { const box = $('#confirm'); box.dataset.display = true; box.style.cssText = ''; - $('b', box).textContent = ((cachedStyles.byId.get(id) || {}).style || {}).name; + $('b', box).textContent = (cachedStyles.byId.get(id) || {}).name; $('[data-cmd="ok"]', box).onclick = () => confirm(true); $('[data-cmd="cancel"]', box).onclick = () => confirm(false); window.onkeydown = event => { @@ -219,6 +259,18 @@ class EntryOnClick { } } + static indicator(event) { + const entry = getClickedStyleElement(event); + const info = template.regexpProblemExplanation.cloneNode(true); + $$('#' + info.id).forEach(el => el.remove()); + $$('a', info).forEach(el => (el.onclick = openURLandHide)); + $$('button', info).forEach(el => (el.onclick = EntryOnClick.closeExplanation)); + entry.appendChild(info); + } + + static closeExplanation(event) { + $('#regexp-explanation').remove(); + } } @@ -264,24 +316,51 @@ function openURLandHide(event) { function handleUpdate(style) { - const styleElement = $(`[style-id="${style.id}"]`, installed); - if (styleElement) { - installed.replaceChild(createStyleElement(style), styleElement); - } else { - getActiveTabRealURL().then(url => { - if (getApplicableSections(style, url).length) { - // a new style for the current url is installed - $('#unavailable').style.display = 'none'; - installed.appendChild(createStyleElement(style)); - } - }); + if ($('#style-' + style.id)) { + createStyleElement({style}); + return; + } + // Add an entry when a new style for the current url is installed + if (tabURL && getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) { + $('#unavailable').style.display = 'none'; + createStyleElement({style}); } } function handleDelete(id) { - const styleElement = $(`[style-id="${id}"]`, installed); - if (styleElement) { - installed.removeChild(styleElement); + $$('#style-' + id).forEach(el => el.remove()); +} + + +/* + According to CSS4 @document specification the entire URL must match. + Stylish-for-Chrome implemented it incorrectly since the very beginning. + We'll detect styles that abuse the bug by finding the sections that + would have been applied by Stylish but not by us as we follow the spec. + Additionally we'll check for invalid regexps. +*/ +function detectSloppyRegexps({entry, style}) { + const { + appliedSections = getApplicableSections({style, matchUrl: tabURL}), + wannabeSections = getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}), + } = style; + + compileStyleRegExps({style, compileAll: true}); + entry.hasInvalidRegexps = wannabeSections.some(section => + section.regexps.some(rx => !cachedStyles.regexps.has(rx))); + entry.sectionsSkipped = wannabeSections.length - appliedSections.length; + + if (!appliedSections.length) { + entry.classList.add('not-applied'); + $('.style-name', entry).title = t('styleNotAppliedRegexpProblemTooltip'); + } + if (entry.sectionsSkipped || entry.hasInvalidRegexps) { + entry.classList.toggle('regexp-partial', entry.sectionsSkipped); + entry.classList.toggle('regexp-invalid', entry.hasInvalidRegexps); + const indicator = template.regexpProblemIndicator.cloneNode(true); + indicator.appendChild(document.createTextNode(entry.sectionsSkipped || '!')); + indicator.onclick = EntryOnClick.indicator; + $('.main-controls', entry).appendChild(indicator); } } diff --git a/storage.js b/storage.js index 3f3c3e33..656fcbf0 100644 --- a/storage.js +++ b/storage.js @@ -28,7 +28,7 @@ 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 @@ -39,11 +39,11 @@ var cachedStyles, prefs; cachedStyles = bg && bg.cachedStyles || { bg, list: null, - noCode: 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: [], @@ -89,15 +89,12 @@ function getStyles(options, callback) { const os = tx.objectStore('styles'); os.getAll().onsuccess = event => { cachedStyles.list = event.target.result || []; - cachedStyles.noCode = []; cachedStyles.byId.clear(); for (const style of cachedStyles.list) { - const noCode = getStyleWithNoCode(style); - cachedStyles.noCode.push(noCode); - cachedStyles.byId.set(style.id, {style, noCode}); - compileStyleRegExps(style); + cachedStyles.byId.set(style.id, style); + compileStyleRegExps({style}); } - //console.log('%s getStyles %s, invoking cached callbacks: %o', (performance.now() - t0).toFixed(1), JSON.stringify(options), cachedStyles.mutex.onDone.map(e => JSON.stringify(e.options))) + //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; @@ -134,19 +131,16 @@ function invalidateCache(andNotify, {added, updated, deletedId} = {}) { if (updated) { const cached = cachedStyles.byId.get(updated.id); if (cached) { - Object.assign(cached.style, updated); - Object.assign(cached.noCode, getStyleWithNoCode(updated)); - //console.log('cache: updated', updated); + Object.assign(cached, updated); + //console.debug('cache: updated', updated); } cachedStyles.filters.clear(); return; } if (added) { - const noCode = getStyleWithNoCode(added); cachedStyles.list.push(added); - cachedStyles.noCode.push(noCode); - cachedStyles.byId.set(added.id, {style: added, noCode}); - //console.log('cache: added', added); + cachedStyles.byId.set(added.id, added); + //console.debug('cache: added', added); cachedStyles.filters.clear(); return; } @@ -155,46 +149,47 @@ function invalidateCache(andNotify, {added, updated, deletedId} = {}) { if (deletedStyle) { const cachedIndex = cachedStyles.list.indexOf(deletedStyle); cachedStyles.list.splice(cachedIndex, 1); - cachedStyles.noCode.splice(cachedIndex, 1); cachedStyles.byId.delete(deletedId); - //console.log('cache: deleted', deletedStyle); + //console.debug('cache: deleted', deletedStyle); cachedStyles.filters.clear(); return; } } cachedStyles.list = null; - cachedStyles.noCode = null; - //console.log('cache cleared'); + //console.debug('cache cleared'); cachedStyles.filters.clear(); } -function filterStyles(options = {}) { +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(); - const enabled = fixBoolean(options.enabled); - const url = 'url' in options ? options.url : null; - const id = 'id' in options ? Number(options.id) : null; - const matchUrl = 'matchUrl' in options ? options.matchUrl : null; - const code = 'code' in options ? options.code : true; - const asHash = 'asHash' in options ? options.asHash : false; + enabled = fixBoolean(enabled); + id = id === null ? null : Number(id); if (enabled === null && url === null && id === null && matchUrl === null && asHash != true) { - //console.log('%c%s filterStyles SKIPPED LOOP %s', 'color:gray', (performance.now() - t0).toFixed(1), JSON.stringify(options)) - return code ? cachedStyles.list : cachedStyles.noCode; + //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' + code + asHash; + const cacheKey = ' ' + enabled + url + '\t' + id + matchUrl + '\t' + asHash + strictRegexp; const cached = cachedStyles.filters.get(cacheKey); if (cached) { - //console.log('%c%s filterStyles REUSED RESPONSE %s', 'color:gray', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + //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(); @@ -212,19 +207,22 @@ function filterStyles(options = {}) { } const styles = id === null - ? (code ? cachedStyles.list : cachedStyles.noCode) - : [(cachedStyles.byId.get(id) || {})[code ? 'style' : 'noCode']]; + ? 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 = (asHash || matchUrl !== null) && getApplicableSections(style, matchUrl); + const sections = needSections && + getApplicableSections({style, matchUrl, strictRegexp, stopOnFirst: !asHash}); if (asHash) { if (sections.length) { filtered[style.id] = sections; @@ -234,7 +232,7 @@ function filterStyles(options = {}) { } } } - //console.log('%s filterStyles %s', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + //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(), @@ -307,7 +305,7 @@ function saveStyle(style) { os.put(style).onsuccess = eventPut => { style.id = style.id || eventPut.target.result; invalidateCache(notify, existed ? {updated: style} : {added: style}); - compileStyleRegExps(style); + compileStyleRegExps({style}); if (notify) { notifyAllTabs({ method: existed ? 'styleUpdated' : 'styleAdded', @@ -338,7 +336,7 @@ function saveStyle(style) { // Give it the ID that was generated style.id = event.target.result; invalidateCache(notify, {added: style}); - compileStyleRegExps(style); + compileStyleRegExps({style}); if (notify) { notifyAllTabs({method: 'styleAdded', style, reason}); } @@ -434,68 +432,88 @@ function getType(o) { } -function getApplicableSections(style, url) { +function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) { + //let t0 = 0; const sections = []; checkingSections: for (const section of style.sections) { - // only http, https, file, ftp, and chrome-extension://OWN_EXTENSION_ID allowed - if (!url.startsWith('http') - && !url.startsWith('ftp') - && !url.startsWith('file') - && !url.startsWith(OWN_ORIGIN)) { - continue checkingSections; - } - if (section.urls.length == 0 - && section.domains.length == 0 - && section.urlPrefixes.length == 0 - && section.regexps.length == 0) { - sections.push(section); - continue checkingSections; - } - if (section.urls.indexOf(url) != -1) { - sections.push(section); - continue checkingSections; - } - for (const urlPrefix of section.urlPrefixes) { - if (url.startsWith(urlPrefix)) { - sections.push(section); + 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; } - } - const urlDomains = cachedStyles.urlDomains.get(url) || getDomains(url); - for (const domain of urlDomains) { - if (section.domains.indexOf(domain) != -1) { - sections.push(section); - continue checkingSections; + if (section.urls.length == 0 + && section.domains.length == 0 + && section.urlPrefixes.length == 0 + && section.regexps.length == 0) { + break andCollect; } - } - for (const regexp of section.regexps) { - let rx = cachedStyles.regexps.get(regexp); - if (rx == false) { - // bad regexp - continue; + if (section.urls.indexOf(matchUrl) != -1) { + break andCollect; } - if (!rx) { - rx = tryRegExp('^(?:' + regexp + ')$'); - cachedStyles.regexps.set(regexp, rx || false); - if (!rx) { - // bad regexp - continue; + for (const urlPrefix of section.urlPrefixes) { + if (matchUrl.startsWith(urlPrefix)) { + break andCollect; } } - if (rx.test(url)) { - sections.push(section); - continue checkingSections; + 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.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; } } } - // ignore @namespace-only results - if (sections.length == 1 - && sections[0].code - && sections[0].code.indexOf('@namespace') >= 0 - && sections[0].code.replace(RX_CSS_COMMENTS, '').replace(RX_NAMESPACE, '').trim() == '') { - return []; - } + //t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions return sections; } @@ -888,18 +906,22 @@ function styleSectionsEqual(styleA, styleB) { } -function compileStyleRegExps(style) { +function compileStyleRegExps({style, compileAll}) { const t0 = performance.now(); for (const section of style.sections || []) { for (const regexp of section.regexps) { - // we want to match the full url, so add ^ and $ if not already present - if (cachedStyles.regexps.has(regexp)) { - continue; - } - const rx = tryRegExp('^(?:' + regexp + ')$'); - cachedStyles.regexps.set(regexp, rx || false); - if (performance.now() - t0 > 100) { - return; + 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; + } } } }