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:
tophf 2020-11-08 13:31:07 +03:00
parent 7d18376cf2
commit ff1fa07267
9 changed files with 174 additions and 159 deletions

View File

@ -27,7 +27,7 @@
if (!linter) { if (!linter) {
return; return;
} }
const storageName = linter === 'stylelint' ? 'editorStylelintConfig' : 'editorCSSLintConfig'; const storageName = chromeSync.LZ_KEY[linter];
const getRules = memoize(linter === 'stylelint' ? const getRules = memoize(linter === 'stylelint' ?
editorWorker.getStylelintRules : editorWorker.getCsslintRules); editorWorker.getStylelintRules : editorWorker.getCsslintRules);
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint'; const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';

View File

@ -4,13 +4,13 @@
(() => { (() => {
registerLinters({ registerLinters({
csslint: { csslint: {
storageName: 'editorCSSLintConfig', storageName: chromeSync.LZ_KEY.csslint,
lint: csslint, lint: csslint,
validMode: mode => mode === 'css', validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config) getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config)
}, },
stylelint: { stylelint: {
storageName: 'editorStylelintConfig', storageName: chromeSync.LZ_KEY.stylelint,
lint: stylelint, lint: stylelint,
validMode: () => true, validMode: () => true,
getConfig: config => ({ getConfig: config => ({

View File

@ -157,7 +157,7 @@ function SourceEditor() {
style.sourceCode = ''; style.sourceCode = '';
placeholderName = `${style.name || t('usercssReplaceTemplateName')} - ${new Date().toLocaleString()}`; 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 || DEFAULT_CODE;
code = code.replace(/@name(\s*)(?=[\r\n])/, (str, space) => code = code.replace(/@name(\s*)(?=[\r\n])/, (str, space) =>
`${str}${space ? '' : ' '}${placeholderName}`); `${str}${space ? '' : ' '}${placeholderName}`);
@ -247,9 +247,10 @@ function SourceEditor() {
// save template // save template
if (err.code === 'missingValue' && meta.includes('@name')) { if (err.code === 'missingValue' && meta.includes('@name')) {
const key = chromeSync.LZ_KEY.usercssTemplate;
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok && messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue('usercssTemplate', code) chromeSync.setLZValue(key, code)
.then(() => chromeSync.getLZValue('usercssTemplate')) .then(() => chromeSync.getLZValue(key))
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving')))); .then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving'))));
return; return;
} }

View File

@ -135,6 +135,7 @@ window.INJECTED !== 1 && (() => {
// This direct assignment allows IDEs to provide correct autocomplete for methods // This direct assignment allows IDEs to provide correct autocomplete for methods
const prefs = window.prefs = { const prefs = window.prefs = {
STORAGE_KEY,
initializing, initializing,
defaults, defaults,
get values() { get values() {

View File

@ -35,7 +35,7 @@ const [chromeLocal, chromeSync] = (() => {
setValue: (key, value) => wrapper.set({[key]: value}), setValue: (key, value) => wrapper.set({[key]: value}),
getLZValue: key => wrapper.getLZValues([key]).then(data => data[key]), getLZValue: key => wrapper.getLZValues([key]).then(data => data[key]),
getLZValues: keys => getLZValues: (keys = Object.values(wrapper.LZ_KEY)) =>
Promise.all([ Promise.all([
wrapper.get(keys), wrapper.get(keys),
loadLZStringScript(), loadLZStringScript(),
@ -64,3 +64,9 @@ const [chromeLocal, chromeSync] = (() => {
(window.LZString = window.LZString || window.LZStringUnsafe)); (window.LZString = window.LZString || window.LZStringUnsafe));
} }
})(); })();
chromeSync.LZ_KEY = {
csslint: 'editorCSSLintConfig',
stylelint: 'editorStylelintConfig',
usercssTemplate: 'usercssTemplate',
};

View File

@ -169,6 +169,7 @@
<script src="msgbox/msgbox.js"></script> <script src="msgbox/msgbox.js"></script>
<script src="js/sections-util.js"></script> <script src="js/sections-util.js"></script>
<script src="js/storage-util.js"></script> <script src="js/storage-util.js"></script>
<script src="js/script-loader.js"></script>
</head> </head>
<body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage"> <body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage">

View File

@ -1,6 +1,22 @@
/* global messageBox styleSectionsEqual API onDOMready /* global
tryJSONparse scrollElementIntoView $ $$ API $create t animateElement $
styleJSONseemsValid bulkChangeQueue */ $$
$create
animateElement
API
bulkChangeQueue
CHROME
chromeSync
deepEqual
messageBox
onDOMready
prefs
scrollElementIntoView
styleJSONseemsValid
styleSectionsEqual
t
tryJSONparse
*/
'use strict'; 'use strict';
const STYLISH_DUMP_FILE_EXT = '.txt'; const STYLISH_DUMP_FILE_EXT = '.txt';
@ -21,9 +37,8 @@ onDOMready().then(() => {
this.classList.remove('fadeout'); this.classList.remove('fadeout');
} }
}, },
async ondragend() { ondragend() {
await animateElement(this, 'fadeout', 'dropzone'); animateElement(this, 'fadeout', 'dropzone');
this.style.animationDuration = '';
}, },
ondragleave(event) { ondragleave(event) {
try { try {
@ -36,7 +51,6 @@ onDOMready().then(() => {
} }
}, },
ondrop(event) { ondrop(event) {
this.ondragend();
if (event.dataTransfer.files.length) { if (event.dataTransfer.files.length) {
event.preventDefault(); event.preventDefault();
if ($('#only-updates input').checked) { if ($('#only-updates input').checked) {
@ -44,6 +58,8 @@ onDOMready().then(() => {
} }
importFromFile({file: event.dataTransfer.files[0]}); 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) { if (file || fileInput.value !== fileInput.initialValue) {
file = file || fileInput.files[0]; file = file || fileInput.files[0];
if (file.size > 100e6) { if (file.size > 100e6) {
console.warn("100MB backup? I don't believe you."); messageBox.alert("100MB backup? I don't believe you.");
importFromString('').then(resolve); resolve();
return; return;
} }
document.body.style.cursor = 'wait';
const fReader = new FileReader(); const fReader = new FileReader();
fReader.onloadend = event => { fReader.onloadend = event => {
fileInput.remove(); fileInput.remove();
const text = event.target.result; const text = event.target.result;
const maybeUsercss = !/^[\s\r\n]*\[/.test(text) && const maybeUsercss = !/^\s*\[/.test(text) && /==UserStyle==/i.test(text);
(text.includes('==UserStyle==') || /==UserStyle==/i.test(text));
if (maybeUsercss) { if (maybeUsercss) {
messageBox.alert(t('dragDropUsercssTabstrip')); 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'); fReader.readAsText(file, 'utf-8');
} }
@ -96,51 +107,33 @@ function importFromFile({fileTypeFilter, file} = {}) {
} }
function importFromString(jsonString) { async function importFromString(jsonString) {
const json = tryJSONparse(jsonString); const json = tryJSONparse(jsonString);
if (!Array.isArray(json)) { const oldStyles = Array.isArray(json) && json.length ? await API.getAllStyles() : [];
return Promise.reject(new Error('the backup is not a valid JSON file')); const oldStylesById = new Map(oldStyles.map(style => [style.id, style]));
} const oldStylesByName = new Map(oldStyles.map(style => [style.name.trim(), style]));
let oldStyles; const items = [];
let oldStylesById; const infos = [];
let oldStylesByName;
const stats = { 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'}, unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'},
metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth'}, metaAndCode: {names: [], ids: [], legend: 'importReportLegendUpdatedBoth', dirty: true},
metaOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedMeta'}, metaOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedMeta', dirty: true},
codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode'}, codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode', dirty: true},
invalid: {names: [], legend: 'importReportLegendInvalid'}, invalid: {names: [], legend: 'importReportLegendInvalid'},
}; };
await Promise.all(json.map(analyze));
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.length = 0;
bulkChangeQueue.time = performance.now(); bulkChangeQueue.time = performance.now();
return API.importManyStyles(items.map(i => i.item)) (await API.importManyStyles(items))
.then(styles => { .forEach((style, i) => updateStats(style, infos[i]));
for (let i = 0; i < styles.length; i++) { return done();
updateStats(styles[i], items[i].info);
}
});
})
.then(done);
function analyze(item, index) { function analyze(item, index) {
if (item && !item.id && item[prefs.STORAGE_KEY]) {
return analyzeStorage(item);
}
if (typeof item !== 'object' || !styleJSONseemsValid(item)) { if (typeof item !== 'object' || !styleJSONseemsValid(item)) {
stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`); stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`);
return; return;
@ -161,17 +154,32 @@ function importFromString(jsonString) {
item.id = byName.id; item.id = byName.id;
oldStyle = byName; oldStyle = byName;
} }
const oldStyleKeys = oldStyle && Object.keys(oldStyle); const metaEqual = oldStyle && deepEqual(oldStyle, item, ['sections', '_rev']);
const metaEqual = oldStyleKeys &&
oldStyleKeys.length === Object.keys(item).length &&
oldStyleKeys.every(k => k === 'sections' || oldStyle[k] === item[k]);
const codeEqual = oldStyle && styleSectionsEqual(oldStyle, item); const codeEqual = oldStyle && styleSectionsEqual(oldStyle, item);
if (metaEqual && codeEqual) { if (metaEqual && codeEqual) {
stats.unchanged.names.push(oldStyle.name); stats.unchanged.names.push(oldStyle.name);
stats.unchanged.ids.push(oldStyle.id); 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) { function sameStyle(oldStyle, newStyle) {
@ -201,31 +209,14 @@ function importFromString(jsonString) {
} }
function done() { 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); 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({ messageBox({
title: t('importReportTitle'), title: t('importReportTitle'),
contents: report.length ? report : t('importReportUnchanged'), contents: $create('#import', report.length ? report : t('importReportUnchanged')),
buttons: [t('confirmClose'), numChanged && t('undo')], buttons: [t('confirmClose'), numChanged && t('undo')],
onshow: bindClick, onshow: bindClick,
}) })
@ -234,7 +225,61 @@ function importFromString(jsonString) {
undo(); 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() { function undo() {
@ -289,39 +334,20 @@ function importFromString(jsonString) {
} }
function exportToFile() { async function exportToFile() {
API.getAllStyles().then(styles => { const data = [
// https://crbug.com/714373 Object.assign({
document.documentElement.appendChild( [prefs.STORAGE_KEY]: prefs.values,
$create('iframe', { }, await chromeSync.getLZValues()),
onload() { ...await API.getAllStyles(),
const text = JSON.stringify(styles, null, '\t'); ];
const text = JSON.stringify(data, null, ' ');
const type = 'application/json'; const type = 'application/json';
this.onload = null;
this.contentDocument.body.appendChild(
$create('a', { $create('a', {
href: URL.createObjectURL(new Blob([text], {type})), href: URL.createObjectURL(new Blob([text], {type})),
download: generateFileName(), download: generateFileName(),
type, type,
}) }).dispatchEvent(new MouseEvent('click'));
).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)
});
function generateFileName() { function generateFileName() {
const today = new Date(); const today = new Date();
const dd = ('0' + today.getDate()).substr(-2); const dd = ('0' + today.getDate()).substr(-2);

View File

@ -913,22 +913,6 @@ a:hover {
white-space: nowrap; 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 */ /* drag-n-drop on import button */
.dropzone:after { .dropzone:after {
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);
@ -954,18 +938,22 @@ a:hover {
} }
/* post-import report */ /* 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); background-color: rgba(128, 128, 128, .3);
} }
#message-box details:not(:last-child) { #import details:not(:last-child) {
margin-bottom: 1em; margin-bottom: 1em;
} }
#message-box details small div { #import details small > * {
margin-left: 1.5em; margin-left: 1.5em;
} }
#import details > button {
margin: .5em 1.25em 0;
}
.update-history-log { .update-history-log {
font-size: 11px; font-size: 11px;
white-space: pre; white-space: pre;

View File

@ -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 // Show the option as disabled until the permission is actually granted
const el = $('#styleViaXhr'); 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(() => { prefs.initializing.then(() => {
if (prefs.get('styleViaXhr')) {
el.checked = false; el.checked = false;
});
el.addEventListener('click', () => {
if (el.checked) {
chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError);
} }
}); });
}
} }
// actions // actions