From ff1fa072674e71ff94c636ea4c8974c6dc8e06c9 Mon Sep 17 00:00:00 2001 From: tophf Date: Sun, 8 Nov 2020 13:31:07 +0300 Subject: [PATCH] import/export options in backup json * import options on demand * auto-grant declarativeContent * include lint configs and usercss template * simplify exportFile as crbug.com/798705 was fixed --- edit/linter-config-dialog.js | 2 +- edit/linter-engines.js | 4 +- edit/source-editor.js | 7 +- js/prefs.js | 1 + js/storage-util.js | 8 +- manage.html | 1 + manage/import-export.js | 264 +++++++++++++++++++---------------- manage/manage.css | 26 +--- options/options.js | 20 +-- 9 files changed, 174 insertions(+), 159 deletions(-) diff --git a/edit/linter-config-dialog.js b/edit/linter-config-dialog.js index c570963a..d357cb79 100644 --- a/edit/linter-config-dialog.js +++ b/edit/linter-config-dialog.js @@ -27,7 +27,7 @@ if (!linter) { return; } - const storageName = linter === 'stylelint' ? 'editorStylelintConfig' : 'editorCSSLintConfig'; + const storageName = chromeSync.LZ_KEY[linter]; const getRules = memoize(linter === 'stylelint' ? editorWorker.getStylelintRules : editorWorker.getCsslintRules); const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint'; diff --git a/edit/linter-engines.js b/edit/linter-engines.js index 65755434..8ec09144 100644 --- a/edit/linter-engines.js +++ b/edit/linter-engines.js @@ -4,13 +4,13 @@ (() => { registerLinters({ csslint: { - storageName: 'editorCSSLintConfig', + storageName: chromeSync.LZ_KEY.csslint, lint: csslint, validMode: mode => mode === 'css', getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config) }, stylelint: { - storageName: 'editorStylelintConfig', + storageName: chromeSync.LZ_KEY.stylelint, lint: stylelint, validMode: () => true, getConfig: config => ({ diff --git a/edit/source-editor.js b/edit/source-editor.js index 2e2d5be3..ca8cd183 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -157,7 +157,7 @@ function SourceEditor() { style.sourceCode = ''; placeholderName = `${style.name || t('usercssReplaceTemplateName')} - ${new Date().toLocaleString()}`; - let code = await chromeSync.getLZValue('usercssTemplate'); + let code = await chromeSync.getLZValue(chromeSync.LZ_KEY.usercssTemplate); code = code || DEFAULT_CODE; code = code.replace(/@name(\s*)(?=[\r\n])/, (str, space) => `${str}${space ? '' : ' '}${placeholderName}`); @@ -247,9 +247,10 @@ function SourceEditor() { // save template if (err.code === 'missingValue' && meta.includes('@name')) { + const key = chromeSync.LZ_KEY.usercssTemplate; messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok && - chromeSync.setLZValue('usercssTemplate', code) - .then(() => chromeSync.getLZValue('usercssTemplate')) + chromeSync.setLZValue(key, code) + .then(() => chromeSync.getLZValue(key)) .then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving')))); return; } diff --git a/js/prefs.js b/js/prefs.js index cba6e0d9..dc7e812a 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -135,6 +135,7 @@ window.INJECTED !== 1 && (() => { // This direct assignment allows IDEs to provide correct autocomplete for methods const prefs = window.prefs = { + STORAGE_KEY, initializing, defaults, get values() { diff --git a/js/storage-util.js b/js/storage-util.js index 2e966523..42e2931a 100644 --- a/js/storage-util.js +++ b/js/storage-util.js @@ -35,7 +35,7 @@ const [chromeLocal, chromeSync] = (() => { setValue: (key, value) => wrapper.set({[key]: value}), getLZValue: key => wrapper.getLZValues([key]).then(data => data[key]), - getLZValues: keys => + getLZValues: (keys = Object.values(wrapper.LZ_KEY)) => Promise.all([ wrapper.get(keys), loadLZStringScript(), @@ -64,3 +64,9 @@ const [chromeLocal, chromeSync] = (() => { (window.LZString = window.LZString || window.LZStringUnsafe)); } })(); + +chromeSync.LZ_KEY = { + csslint: 'editorCSSLintConfig', + stylelint: 'editorStylelintConfig', + usercssTemplate: 'usercssTemplate', +}; diff --git a/manage.html b/manage.html index 28b885f5..7f57c575 100644 --- a/manage.html +++ b/manage.html @@ -169,6 +169,7 @@ + diff --git a/manage/import-export.js b/manage/import-export.js index 5c19b140..7c29d53c 100644 --- a/manage/import-export.js +++ b/manage/import-export.js @@ -1,6 +1,22 @@ -/* global messageBox styleSectionsEqual API onDOMready - tryJSONparse scrollElementIntoView $ $$ API $create t animateElement - styleJSONseemsValid bulkChangeQueue */ +/* global + $ + $$ + $create + animateElement + API + bulkChangeQueue + CHROME + chromeSync + deepEqual + messageBox + onDOMready + prefs + scrollElementIntoView + styleJSONseemsValid + styleSectionsEqual + t + tryJSONparse +*/ 'use strict'; const STYLISH_DUMP_FILE_EXT = '.txt'; @@ -21,9 +37,8 @@ onDOMready().then(() => { this.classList.remove('fadeout'); } }, - async ondragend() { - await animateElement(this, 'fadeout', 'dropzone'); - this.style.animationDuration = ''; + ondragend() { + animateElement(this, 'fadeout', 'dropzone'); }, ondragleave(event) { try { @@ -36,7 +51,6 @@ onDOMready().then(() => { } }, ondrop(event) { - this.ondragend(); if (event.dataTransfer.files.length) { event.preventDefault(); if ($('#only-updates input').checked) { @@ -44,6 +58,8 @@ onDOMready().then(() => { } importFromFile({file: event.dataTransfer.files[0]}); } + /* Run import first for a while, then run fadeout which is very CPU-intensive in Chrome */ + setTimeout(() => this.ondragend(), 250); }, }); }); @@ -69,25 +85,20 @@ function importFromFile({fileTypeFilter, file} = {}) { if (file || fileInput.value !== fileInput.initialValue) { file = file || fileInput.files[0]; if (file.size > 100e6) { - console.warn("100MB backup? I don't believe you."); - importFromString('').then(resolve); + messageBox.alert("100MB backup? I don't believe you."); + resolve(); return; } - document.body.style.cursor = 'wait'; const fReader = new FileReader(); fReader.onloadend = event => { fileInput.remove(); const text = event.target.result; - const maybeUsercss = !/^[\s\r\n]*\[/.test(text) && - (text.includes('==UserStyle==') || /==UserStyle==/i.test(text)); + const maybeUsercss = !/^\s*\[/.test(text) && /==UserStyle==/i.test(text); if (maybeUsercss) { messageBox.alert(t('dragDropUsercssTabstrip')); - return; + } else { + importFromString(text).then(resolve); } - importFromString(text).then(numStyles => { - document.body.style.cursor = ''; - resolve(numStyles); - }); }; fReader.readAsText(file, 'utf-8'); } @@ -96,51 +107,33 @@ function importFromFile({fileTypeFilter, file} = {}) { } -function importFromString(jsonString) { +async function importFromString(jsonString) { const json = tryJSONparse(jsonString); - if (!Array.isArray(json)) { - return Promise.reject(new Error('the backup is not a valid JSON file')); - } - let oldStyles; - let oldStylesById; - let oldStylesByName; + const oldStyles = Array.isArray(json) && json.length ? await API.getAllStyles() : []; + const oldStylesById = new Map(oldStyles.map(style => [style.id, style])); + const oldStylesByName = new Map(oldStyles.map(style => [style.name.trim(), style])); + const items = []; + const infos = []; const stats = { - added: {names: [], ids: [], legend: 'importReportLegendAdded'}, - unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'}, - metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth'}, - metaOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedMeta'}, - codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode'}, - invalid: {names: [], legend: 'importReportLegendInvalid'}, + options: {names: [], isOptions: true, legend: 'optionsHeading'}, + added: {names: [], ids: [], legend: 'importReportLegendAdded', dirty: true}, + unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'}, + metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth', dirty: true}, + metaOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedMeta', dirty: true}, + codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode', dirty: true}, + invalid: {names: [], legend: 'importReportLegendInvalid'}, }; - - return API.getAllStyles().then(styles => { - // make a copy of the current database, that may be used when we want to - // undo - oldStyles = styles; - oldStylesById = new Map( - oldStyles.map(style => [style.id, style])); - oldStylesByName = json.length && new Map( - oldStyles.map(style => [style.name.trim(), style])); - - const items = []; - json.forEach((item, i) => { - const info = analyze(item, i); - if (info) { - items.push({info, item}); - } - }); - bulkChangeQueue.length = 0; - bulkChangeQueue.time = performance.now(); - return API.importManyStyles(items.map(i => i.item)) - .then(styles => { - for (let i = 0; i < styles.length; i++) { - updateStats(styles[i], items[i].info); - } - }); - }) - .then(done); + await Promise.all(json.map(analyze)); + bulkChangeQueue.length = 0; + bulkChangeQueue.time = performance.now(); + (await API.importManyStyles(items)) + .forEach((style, i) => updateStats(style, infos[i])); + return done(); function analyze(item, index) { + if (item && !item.id && item[prefs.STORAGE_KEY]) { + return analyzeStorage(item); + } if (typeof item !== 'object' || !styleJSONseemsValid(item)) { stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`); return; @@ -161,17 +154,32 @@ function importFromString(jsonString) { item.id = byName.id; oldStyle = byName; } - const oldStyleKeys = oldStyle && Object.keys(oldStyle); - const metaEqual = oldStyleKeys && - oldStyleKeys.length === Object.keys(item).length && - oldStyleKeys.every(k => k === 'sections' || oldStyle[k] === item[k]); + const metaEqual = oldStyle && deepEqual(oldStyle, item, ['sections', '_rev']); const codeEqual = oldStyle && styleSectionsEqual(oldStyle, item); if (metaEqual && codeEqual) { stats.unchanged.names.push(oldStyle.name); stats.unchanged.ids.push(oldStyle.id); - return; + } else { + items.push(item); + infos.push({oldStyle, metaEqual, codeEqual}); + } + } + + async function analyzeStorage(storage) { + analyzePrefs(storage[prefs.STORAGE_KEY], Object.keys(prefs.defaults), prefs.values, true); + delete storage[prefs.STORAGE_KEY]; + if (Object.keys(storage).length) { + analyzePrefs(storage, Object.values(chromeSync.LZ_KEY), await chromeSync.getLZValues()); + } + } + + function analyzePrefs(obj, validKeys, values, isPref) { + for (const [key, val] of Object.entries(obj || {})) { + const isValid = validKeys.includes(key); + if (!isValid || !deepEqual(val, values[key])) { + stats.options.names.push({name: key, val, isValid, isPref}); + } } - return {oldStyle, metaEqual, codeEqual}; } function sameStyle(oldStyle, newStyle) { @@ -201,31 +209,14 @@ function importFromString(jsonString) { } function done() { - const numChanged = stats.metaAndCode.names.length + - stats.metaOnly.names.length + - stats.codeOnly.names.length + - stats.added.names.length; - const report = Object.keys(stats) - .filter(kind => stats[kind].names.length) - .map(kind => { - const {ids, names, legend} = stats[kind]; - const listItemsWithId = (name, i) => - $create('div', {dataset: {id: ids[i]}}, name); - const listItems = name => - $create('div', name); - const block = - $create('details', {dataset: {id: kind}}, [ - $create('summary', - $create('b', names.length + ' ' + t(legend))), - $create('small', - names.map(ids ? listItemsWithId : listItems)), - ]); - return block; - }); scrollTo(0, 0); + const entries = Object.entries(stats); + const numChanged = entries.reduce((sum, [, val]) => + sum + (val.dirty ? val.names.length : 0), 0); + const report = entries.map(renderStats).filter(Boolean); messageBox({ title: t('importReportTitle'), - contents: report.length ? report : t('importReportUnchanged'), + contents: $create('#import', report.length ? report : t('importReportUnchanged')), buttons: [t('confirmClose'), numChanged && t('undo')], onshow: bindClick, }) @@ -234,7 +225,61 @@ function importFromString(jsonString) { undo(); } }); - return Promise.resolve(numChanged); + } + + function renderStats([id, {ids, names, legend, isOptions}]) { + return names.length && + $create('details', {dataset: {id}, open: isOptions}, [ + $create('summary', + $create('b', (isOptions ? '' : names.length + ' ') + t(legend))), + $create('small', + names.map(ids ? listItemsWithId : isOptions ? listOptions : listItems, ids)), + isOptions && names.some(_ => _.isValid) && + $create('button', {onclick: importOptions}, t('importLabel')), + ]); + } + + function listOptions({name, isValid}) { + return $create(isValid ? 'div' : 'del', + name + (isValid ? '' : ` (${t(stats.invalid.legend)})`)); + } + + function listItems(name) { + return $create('div', name); + } + + /** @this stats..ids */ + function listItemsWithId(name, i) { + return $create('div', {dataset: {id: this[i]}}, name); + } + + async function importOptions() { + // Must acquire the permission before setting the pref + if (CHROME && !chrome.declarativeContent && + stats.options.names.find(_ => _.name === 'styleViaXhr' && _.isValid && _.val)) { + await new Promise(resolve => + chrome.permissions.request({permissions: ['declarativeContent']}, resolve)); + } + const oldStorage = await chromeSync.get(); + for (const {name, val, isValid, isPref} of stats.options.names) { + if (isValid) { + if (isPref) { + prefs.set(name, val); + } else { + chromeSync.setLZValue(name, val); + } + } + } + const label = this.textContent; + this.textContent = t('undo'); + this.onclick = async () => { + const curKeys = Object.keys(await chromeSync.get()); + const keysToRemove = curKeys.filter(k => !oldStorage.hasOwnProperty(k)); + await chromeSync.set(oldStorage); + await chromeSync.remove(keysToRemove); + this.textContent = label; + this.onclick = importOptions; + }; } function undo() { @@ -289,39 +334,20 @@ function importFromString(jsonString) { } -function exportToFile() { - API.getAllStyles().then(styles => { - // https://crbug.com/714373 - document.documentElement.appendChild( - $create('iframe', { - onload() { - const text = JSON.stringify(styles, null, '\t'); - const type = 'application/json'; - this.onload = null; - this.contentDocument.body.appendChild( - $create('a', { - href: URL.createObjectURL(new Blob([text], {type})), - download: generateFileName(), - type, - }) - ).dispatchEvent(new MouseEvent('click')); - }, - // we can't use display:none as some browsers are ignoring such iframes - style: ` - all: unset; - width: 0; - height: 0; - position: fixed; - opacity: 0; - border: none; - `.replace(/;/g, '!important;'), - }) - ); - // we don't remove the iframe or the object URL because the browser may show - // a download dialog and we don't know how long it'll take until the user confirms it - // (some browsers like Vivaldi can't download if we revoke the URL) - }); - +async function exportToFile() { + const data = [ + Object.assign({ + [prefs.STORAGE_KEY]: prefs.values, + }, await chromeSync.getLZValues()), + ...await API.getAllStyles(), + ]; + const text = JSON.stringify(data, null, ' '); + const type = 'application/json'; + $create('a', { + href: URL.createObjectURL(new Blob([text], {type})), + download: generateFileName(), + type, + }).dispatchEvent(new MouseEvent('click')); function generateFileName() { const today = new Date(); const dd = ('0' + today.getDate()).substr(-2); diff --git a/manage/manage.css b/manage/manage.css index 95652b15..419b0762 100644 --- a/manage/manage.css +++ b/manage/manage.css @@ -913,22 +913,6 @@ a:hover { white-space: nowrap; } -#import ul { - margin-left: 0; - padding-left: 0; - list-style: none; -} - -#import li { - margin-bottom: .5em; -} - -#import pre { - background: #eee; - overflow: auto; - margin: 0 0 .5em 0; -} - /* drag-n-drop on import button */ .dropzone:after { background-color: rgba(0, 0, 0, 0.7); @@ -954,18 +938,22 @@ a:hover { } /* post-import report */ -#message-box details:not([data-id="invalid"]) div:hover { +#import details:not([data-id="invalid"]) div:hover { background-color: rgba(128, 128, 128, .3); } -#message-box details:not(:last-child) { +#import details:not(:last-child) { margin-bottom: 1em; } -#message-box details small div { +#import details small > * { margin-left: 1.5em; } +#import details > button { + margin: .5em 1.25em 0; +} + .update-history-log { font-size: 11px; white-space: pre; diff --git a/options/options.js b/options/options.js index d3d3ab01..4d0a1062 100644 --- a/options/options.js +++ b/options/options.js @@ -38,25 +38,17 @@ if (FIREFOX && 'update' in (chrome.commands || {})) { }); } -if (CHROME) { +if (CHROME && !chrome.declarativeContent) { // Show the option as disabled until the permission is actually granted const el = $('#styleViaXhr'); + prefs.initializing.then(() => { + el.checked = false; + }); el.addEventListener('click', () => { - if (el.checked && !chrome.declarativeContent) { - chrome.permissions.request({permissions: ['declarativeContent']}, ok => { - if (chrome.runtime.lastError || !ok) { - el.checked = false; - } - }); + if (el.checked) { + chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); } }); - if (!chrome.declarativeContent) { - prefs.initializing.then(() => { - if (prefs.get('styleViaXhr')) { - el.checked = false; - } - }); - } } // actions