diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ae1f59bd..50229ff4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -323,7 +323,7 @@ "description": "Checkbox to show only locally edited styles" }, "manageOnlyUpdates": { - "message": "Only with updates", + "message": "Only with updates or problems", "description": "Checkbox to show only styles that have updates after check-all-styles-for-updates was performed" }, "manageNewUI": { @@ -592,7 +592,7 @@ "description": "Text that displays when an update check skipped updating the style to avoid losing possible local modifications" }, "updateCheckManualUpdateHint": { - "message": "Do a one-time manual update on its userstyles.org page (your edits will be lost)", + "message": "To force an update (and lose your edits) update each style individually.", "description": "Additional text displayed when an update check skipped updating the style to avoid losing local modifications" }, "updateCheckSucceededNoUpdate": { @@ -600,7 +600,11 @@ "description": "Text that displays when an update check completed and no update is available" }, "updateAllCheckSucceededNoUpdate": { - "message": "All styles are up to date.", + "message": "No updates found.", + "description": "Text that displays when an update all check completed and no updates are available" + }, + "updateAllCheckSucceededSomeEdited": { + "message": "Some updatable styles weren't checked to avoid losing possible local edits.", "description": "Text that displays when an update all check completed and no updates are available" }, "updateCompleted": { diff --git a/manage.css b/manage.css index 2f32e78a..c590126a 100644 --- a/manage.css +++ b/manage.css @@ -294,7 +294,7 @@ summary { cursor: pointer; } -.update-problem .check-update svg { +.newUI .update-problem .check-update svg { fill: #ef6969; } @@ -500,6 +500,10 @@ input[id^="manage.newUI"] { opacity: .35; } +#update-all-no-updates[data-skipped-edited="true"]:after { + content: " __MSG_updateAllCheckSucceededSomeEdited__ __MSG_updateCheckManualUpdateHint__"; +} + /* highlight updated/added styles */ .highlight { animation: highlight 10s cubic-bezier(0,.82,.47,.98); diff --git a/manage.html b/manage.html index 34965c73..6688c987 100644 --- a/manage.html +++ b/manage.html @@ -135,18 +135,26 @@
- +

diff --git a/manage.js b/manage.js index cfb04e66..217ab3ec 100644 --- a/manage.js +++ b/manage.js @@ -365,10 +365,15 @@ Object.assign(handleEvent, { el.lastValue = value; } const enabledFilters = $$('#header [data-filter]').filter(el => getValue(el)); + const buildFilter = hide => + [...enabledFilters.map(el => + el.dataset[hide ? 'filterHide' : 'filter'] + .split(/,\s*/) + .map(s => '.entry' + (hide ? '' : '.hidden') + s)) + ].join(','); Object.assign(filtersSelector, { - hide: enabledFilters.map(el => '.entry:not(.hidden)' + el.dataset.filter).join(','), - unhide: '.entry.hidden' + enabledFilters.map(el => - (':not(' + el.dataset.filter + ')').replace(/^:not\(:not\((.+?)\)\)$/, '$1')).join(''), + hide: buildFilter(true), + unhide: buildFilter(false), }); reapplyFilter(); }, @@ -505,12 +510,13 @@ function checkUpdateAll() { let total = 0; let checked = 0; + let skippedEdited = 0; let updated = 0; - $$('.updatable:not(.can-update)').map(el => checkUpdate(el, {single: false})); - BG.updater.checkAllStyles(observe, {save: false}).then(done); + $$('.updatable:not(.can-update):not(.update-problem)').map(el => checkUpdate(el, {single: false})); + BG.updater.checkAllStyles({observer, save: false}); - function observe(state, value, details) { + function observer(state, value, details) { switch (state) { case BG.updater.COUNT: total = value; @@ -524,37 +530,41 @@ function checkUpdateAll() { // fallthrough case BG.updater.SKIPPED: checked++; + if (details == BG.updater.EDITED || details == BG.updater.MAYBE_EDITED) { + skippedEdited++; + } reportUpdateState(state, value, details); break; + case BG.updater.DONE: + $('#check-all-updates').disabled = false; + $('#apply-all-updates').disabled = false; + renderUpdatesOnlyFilter({check: updated + skippedEdited > 0}); + if (!updated) { + $('#update-all-no-updates').dataset.skippedEdited = skippedEdited > 0; + $('#update-all-no-updates').classList.remove('hidden'); + } + return; } const progress = $('#update-progress'); const maxWidth = progress.parentElement.clientWidth; progress.style.width = Math.round(checked / total * maxWidth) + 'px'; } - - function done() { - $('#check-all-updates').disabled = false; - $('#apply-all-updates').disabled = false; - renderUpdatesOnlyFilter({check: updated > 0}); - if (!updated) { - $('#update-all-no-updates').classList.remove('hidden'); - setTimeout(() => { - $('#update-all-no-updates').classList.add('hidden'); - }, 10e3); - } - } } -function checkUpdate(element, {single = true} = {}) { - $('.update-note', element).textContent = t('checkingForUpdate'); - $('.check-update', element).title = ''; - element.classList.remove('checking-update', 'no-update', 'update-problem'); - element.classList.add('checking-update'); +function checkUpdate(entry, {single = true} = {}) { + $('.update-note', entry).textContent = t('checkingForUpdate'); + $('.check-update', entry).title = ''; if (single) { - const style = BG.cachedStyles.byId.get(element.styleId); - BG.updater.checkStyle(style, reportUpdateState, {save: false}); + BG.updater.checkStyle({ + save: false, + ignoreDigest: entry.classList.contains('update-problem'), + style: BG.cachedStyles.byId.get(entry.styleId), + observer: reportUpdateState, + }); } + entry.classList.remove('checking-update', 'no-update', 'update-problem'); + entry.classList.add('checking-update'); } @@ -569,18 +579,19 @@ function reportUpdateState(state, style, details) { $('#onlyUpdates').classList.remove('hidden'); break; case BG.updater.SKIPPED: { + if (entry.classList.contains('can-update')) { + break; + } if (!details) { details = t('updateCheckFailServerUnreachable'); } else if (typeof details == 'number') { details = t('updateCheckFailBadResponseCode', [details]); - } else if (details == BG.updater.SKIPPED_EDITED) { + } else if (details == BG.updater.EDITED) { details = t('updateCheckSkippedLocallyEdited') + '\n' + t('updateCheckManualUpdateHint'); - } else if (details == BG.updater.SKIPPED_MAYBE_EDITED) { + } else if (details == BG.updater.MAYBE_EDITED) { details = t('updateCheckSkippedMaybeLocallyEdited') + '\n' + t('updateCheckManualUpdateHint'); } - const same = - details == BG.updater.SKIPPED_SAME_MD5 || - details == BG.updater.SKIPPED_SAME_CODE; + const same = details == BG.updater.SAME_MD5 || details == BG.updater.SAME_CODE; const message = same ? t('updateCheckSucceededNoUpdate') : details; entry.classList.add('no-update'); entry.classList.toggle('update-problem', !same); @@ -588,7 +599,7 @@ function reportUpdateState(state, style, details) { $('.check-update', entry).title = newUI.enabled ? message : ''; if (!$('#check-all-updates').disabled) { // this is a single update job so we can decide whether to hide the filter - $('#onlyUpdates').classList.toggle('hidden', !$('.can-update')); + renderUpdatesOnlyFilter({show: $('.can-update, .update-problem')}); } } } @@ -600,10 +611,10 @@ function reportUpdateState(state, style, details) { function renderUpdatesOnlyFilter({show, check} = {}) { const numUpdatable = $$('.can-update').length; - const canUpdate = numUpdatable > 0; + const mightUpdate = numUpdatable > 0 || $('.update-problem'); const checkbox = $('#onlyUpdates input'); - show = show !== undefined ? show : canUpdate; - check = check !== undefined ? show && check : checkbox.checked && canUpdate; + show = show !== undefined ? show : mightUpdate; + check = check !== undefined ? show && check : checkbox.checked && mightUpdate; $('#onlyUpdates').classList.toggle('hidden', !show); checkbox.checked = check; @@ -611,7 +622,7 @@ function renderUpdatesOnlyFilter({show, check} = {}) { const btnApply = $('#apply-all-updates'); if (!btnApply.matches('.hidden')) { - if (canUpdate) { + if (numUpdatable > 0) { btnApply.dataset.value = numUpdatable; } else { btnApply.classList.add('hidden'); diff --git a/options/index.js b/options/index.js index 030edcf5..886badb5 100644 --- a/options/index.js +++ b/options/index.js @@ -41,16 +41,14 @@ function checkUpdates() { let total = 0; let checked = 0; let updated = 0; - const installed = $('#updates-installed'); - const progress = $('#update-progress'); - const maxWidth = progress.parentElement.clientWidth; - progress.style.width = 0; - installed.dataset.value = ''; - document.body.classList.add('update-in-progress'); - BG.updater.checkAllStyles((state, value) => { + const maxWidth = $('#update-progress').parentElement.clientWidth; + BG.updater.checkAllStyles({observer}); + + function observer(state, value) { switch (state) { case BG.updater.COUNT: total = value; + document.body.classList.add('update-in-progress'); break; case BG.updater.UPDATED: updated++; @@ -58,10 +56,11 @@ function checkUpdates() { case BG.updater.SKIPPED: checked++; break; + case BG.updater.DONE: + document.body.classList.remove('update-in-progress'); + return; } - progress.style.width = Math.round(checked / total * maxWidth) + 'px'; - installed.dataset.value = updated || ''; - }).then(() => { - document.body.classList.remove('update-in-progress'); - }); + $('#update-progress').style.width = Math.round(checked / total * maxWidth) + 'px'; + $('#updates-installed').dataset.value = updated || ''; + } } diff --git a/storage.js b/storage.js index a98e95f1..a64c2abc 100644 --- a/storage.js +++ b/storage.js @@ -218,7 +218,7 @@ function saveStyle(style) { const tx = db.transaction(['styles'], 'readwrite'); const os = tx.objectStore('styles'); - const id = style.id !== undefined && style.id !== null ? Number(style.id) : null; + const id = style.id == '0' ? 0 : Number(style.id) || null; const reason = style.reason; const notify = style.notify !== false; delete style.method; @@ -227,15 +227,16 @@ function saveStyle(style) { if (!style.name) { delete style.name; } + let existed, codeIsUpdated; 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), {reason, existed, codeIsUpdated}); + const oldStyle = eventGet.target.result; + existed = Boolean(oldStyle); + codeIsUpdated = !existed || style.sections && !styleSectionsEqual(style, oldStyle); + write(Object.assign({}, oldStyle, style)); }; } else { // Create @@ -247,18 +248,11 @@ function saveStyle(style) { md5Url: null, url: null, originalMd5: null, - }, style), {reason}); + }, style)); } - function write(style, {reason, existed, codeIsUpdated} = {}) { - style.sections = (style.sections || []).map(section => - Object.assign({ - urls: [], - urlPrefixes: [], - domains: [], - regexps: [], - }, section) - ); + function write(style) { + style.sections = normalizeStyleSections(style); os.put(style).onsuccess = event => { style.id = style.id || event.target.result; invalidateCache(existed ? {updated: style} : {added: style}); @@ -271,6 +265,8 @@ function saveStyle(style) { } if (reason == 'update') { updateStyleDigest(style); + } else if (reason == 'import') { + chrome.storage.local.remove(DIGEST_KEY_PREFIX + style.id, ignoreChromeError); } resolve(style); }; @@ -308,10 +304,14 @@ function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirs 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) + || 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) { @@ -534,6 +534,18 @@ function getDomains(url) { } +function normalizeStyleSections({sections}) { + // retain known properties in an arbitrarily predefined order + return (sections || []).map(section => ({ + code: section.code || '', + urls: section.urls || [], + urlPrefixes: section.urlPrefixes || [], + domains: section.domains || [], + regexps: section.regexps || [], + })); +} + + function getStyleDigests(style) { return Promise.all([ chromeLocal.getValue(DIGEST_KEY_PREFIX + style.id), @@ -548,9 +560,11 @@ function updateStyleDigest(style) { } -function calcStyleDigest({sections}) { - const text = new TextEncoder('utf-8').encode(JSON.stringify(sections)); +function calcStyleDigest(style) { + const jsonString = JSON.stringify(normalizeStyleSections(style)); + const text = new TextEncoder('utf-8').encode(jsonString); return crypto.subtle.digest('SHA-1', text).then(hex); + function hex(buffer) { const parts = []; const PAD8 = '00000000'; diff --git a/update.js b/update.js index 1055a55b..7056efa7 100644 --- a/update.js +++ b/update.js @@ -1,4 +1,5 @@ -/* globals getStyles, saveStyle, styleSectionsEqual, getStyleDigests, updateStyleDigest */ +/* global getStyles, saveStyle, styleSectionsEqual */ +/* global getStyleDigests, updateStyleDigest */ 'use strict'; // eslint-disable-next-line no-var @@ -7,55 +8,71 @@ var updater = { COUNT: 'count', UPDATED: 'updated', SKIPPED: 'skipped', - SKIPPED_EDITED: 'locally edited', - SKIPPED_MAYBE_EDITED: 'maybe locally edited', - SKIPPED_SAME_MD5: 'up-to-date: MD5 is unchanged', - SKIPPED_SAME_CODE: 'up-to-date: code sections are unchanged', - SKIPPED_ERROR_MD5: 'error: MD5 is invalid', - SKIPPED_ERROR_JSON: 'error: JSON is invalid', DONE: 'done', + // details for SKIPPED status + EDITED: 'locally edited', + MAYBE_EDITED: 'maybe locally edited', + SAME_MD5: 'up-to-date: MD5 is unchanged', + SAME_CODE: 'up-to-date: code sections are unchanged', + ERROR_MD5: 'error: MD5 is invalid', + ERROR_JSON: 'error: JSON is invalid', + lastUpdateTime: parseInt(localStorage.lastUpdateTime) || Date.now(), - checkAllStyles(observe = () => {}, {save = true} = {}) { + checkAllStyles({observer = () => {}, save = true, ignoreDigest} = {}) { updater.resetInterval(); return new Promise(resolve => { getStyles({}, styles => { styles = styles.filter(style => style.updateUrl); - observe(updater.COUNT, styles.length); + observer(updater.COUNT, styles.length); Promise.all(styles.map(style => - updater.checkStyle(style, observe, {save}) + updater.checkStyle({style, observer, save, ignoreDigest}) )).then(() => { - observe(updater.DONE); + observer(updater.DONE); resolve(); }); }); }); }, - checkStyle(style, observe = () => {}, {save = true} = {}) { + checkStyle({style, observer = () => {}, save = true, ignoreDigest}) { let hasDigest; + /* + Original style digests are calculated in these cases: + * style is installed or updated from server + * style is checked for an update and its code is equal to the server code + + Update check proceeds in these cases: + * style has the original digest and it's equal to the current digest + * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it + * [ignoreDigest: none/false] style doesn't yet have the original digest + so we compare the code to the server code and if it's the same we save the digest, + otherwise we skip the style and report MAYBE_EDITED status + + 'ignoreDigest' option is set on the second manual individual update check on the manage page. + */ return getStyleDigests(style) .then(fetchMd5IfNotEdited) .then(fetchCodeIfMd5Changed) .then(saveIfUpdated) - .then(saved => observe(updater.UPDATED, saved)) - .catch(err => observe(updater.SKIPPED, style, err)); + .then(saved => observer(updater.UPDATED, saved)) + .catch(err => observer(updater.SKIPPED, style, err)); function fetchMd5IfNotEdited([originalDigest, current]) { hasDigest = Boolean(originalDigest); - if (hasDigest && originalDigest != current) { - return Promise.reject(updater.SKIPPED_EDITED); + if (hasDigest && !ignoreDigest && originalDigest != current) { + return Promise.reject(updater.EDITED); } return download(style.md5Url); } function fetchCodeIfMd5Changed(md5) { if (!md5 || md5.length != 32) { - return Promise.reject(updater.SKIPPED_ERROR_MD5); + return Promise.reject(updater.ERROR_MD5); } if (md5 == style.originalMd5 && hasDigest) { - return Promise.reject(updater.SKIPPED_SAME_MD5); + return Promise.reject(updater.SAME_MD5); } return download(style.updateUrl); } @@ -63,16 +80,16 @@ var updater = { function saveIfUpdated(text) { const json = tryJSONparse(text); if (!styleJSONseemsValid(json)) { - return Promise.reject(updater.SKIPPED_ERROR_JSON); + return Promise.reject(updater.ERROR_JSON); } json.id = style.id; if (styleSectionsEqual(json, style)) { - if (!hasDigest) { - updateStyleDigest(json); - } - return Promise.reject(updater.SKIPPED_SAME_CODE); - } else if (!hasDigest) { - return Promise.reject(updater.SKIPPED_MAYBE_EDITED); + // JSONs may have different order of items even if sections are effectively equal + // so we'll update the digest anyway + updateStyleDigest(json); + return Promise.reject(updater.SAME_CODE); + } else if (!hasDigest && !ignoreDigest) { + return Promise.reject(updater.MAYBE_EDITED); } return !save ? json : saveStyle(Object.assign(json, {