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
This commit is contained in:
		
							parent
							
								
									7d18376cf2
								
							
						
					
					
						commit
						ff1fa07267
					
				|  | @ -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'; | ||||
|  |  | |||
|  | @ -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 => ({ | ||||
|  |  | |||
|  | @ -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; | ||||
|           } | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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', | ||||
| }; | ||||
|  |  | |||
|  | @ -169,6 +169,7 @@ | |||
|   <script src="msgbox/msgbox.js"></script> | ||||
|   <script src="js/sections-util.js"></script> | ||||
|   <script src="js/storage-util.js"></script> | ||||
|   <script src="js/script-loader.js"></script> | ||||
| </head> | ||||
| 
 | ||||
| <body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage"> | ||||
|  |  | |||
|  | @ -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'}, | ||||
|     options: {names: [], isOptions: true, legend: 'optionsHeading'}, | ||||
|     added: {names: [], ids: [], legend: 'importReportLegendAdded', dirty: true}, | ||||
|     unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'}, | ||||
|     metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth'}, | ||||
|     metaOnly:    {names: [], ids: [], legend: 'importReportLegendUpdatedMeta'}, | ||||
|     codeOnly:    {names: [], ids: [], legend: 'importReportLegendUpdatedCode'}, | ||||
|     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}); | ||||
|       } | ||||
|     }); | ||||
|   await Promise.all(json.map(analyze)); | ||||
|   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 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.<item>.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'); | ||||
| 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'; | ||||
|           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)
 | ||||
|   }); | ||||
| 
 | ||||
|   }).dispatchEvent(new MouseEvent('click')); | ||||
|   function generateFileName() { | ||||
|     const today = new Date(); | ||||
|     const dd = ('0' + today.getDate()).substr(-2); | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -38,26 +38,18 @@ 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'); | ||||
|   el.addEventListener('click', () => { | ||||
|     if (el.checked && !chrome.declarativeContent) { | ||||
|       chrome.permissions.request({permissions: ['declarativeContent']}, ok => { | ||||
|         if (chrome.runtime.lastError || !ok) { | ||||
|           el.checked = false; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   }); | ||||
|   if (!chrome.declarativeContent) { | ||||
|   prefs.initializing.then(() => { | ||||
|       if (prefs.get('styleViaXhr')) { | ||||
|     el.checked = false; | ||||
|   }); | ||||
|   el.addEventListener('click', () => { | ||||
|     if (el.checked) { | ||||
|       chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| } | ||||
| 
 | ||||
| // actions
 | ||||
| $('#options-close-icon').onclick = () => { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user