Import styles: show report with Undo button

This commit is contained in:
tophf 2017-03-24 05:51:44 +03:00
parent 4bcfcb1503
commit b1c19bdf3d
8 changed files with 433 additions and 110 deletions

View File

@ -1,9 +1,8 @@
/* globals getStyles, saveStyle, invalidateCache, refreshAllTabs, handleUpdate */
'use strict';
var STYLISH_DUMP_FILE_EXT = '.txt';
var STYLISH_DUMPFILE_EXTENSION = '.json';
var STYLISH_DEFAULT_SAVE_NAME = 'stylus-mm-dd-yyyy' + STYLISH_DUMP_FILE_EXT;
const STYLISH_DUMP_FILE_EXT = '.txt';
const STYLUS_BACKUP_FILE_EXT = '.json';
function importFromFile({fileTypeFilter, file} = {}) {
return new Promise(resolve => {
@ -47,52 +46,184 @@ function importFromFile({fileTypeFilter, file} = {}) {
function importFromString(jsonString) {
const json = runTryCatch(() => Array.from(JSON.parse(jsonString))) || [];
const numStyles = json.length;
const oldStyles = json.length && deepCopyStyles();
const oldStylesByName = json.length && new Map(
oldStyles.map(style => [style.name.trim(), style]));
const stats = {
added: {names: [], ids: [], legend: 'added'},
unchanged: {names: [], ids: [], legend: 'identical skipped'},
metaAndCode: {names: [], ids: [], legend: 'updated both meta info and code'},
metaOnly: {names: [], ids: [], legend: 'updated meta info'},
codeOnly: {names: [], ids: [], legend: 'updated code'},
invalid: {names: [], legend: 'invalid skipped'},
};
let index = 0;
return new Promise(proceed);
if (numStyles) {
invalidateCache(true);
function proceed(resolve) {
while (index < json.length) {
const item = json[index++];
if (!item || !item.name || !item.name.trim() || typeof item != 'object'
|| (item.sections && !(item.sections instanceof Array))) {
stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`);
continue;
}
return new Promise(resolve => {
proceed();
function proceed() {
const nextStyle = json.shift();
if (nextStyle) {
saveStyle(nextStyle, {notify: false}).then(style => {
item.name = item.name.trim();
const byId = (cachedStyles.byId.get(item.id) || {}).style;
const byName = oldStylesByName.get(item.name);
const oldStyle = byId && byId.name.trim() == item.name || !byName ? byId : byName;
if (oldStyle == byName && byName) {
item.id = byName.id;
}
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 codeEqual = oldStyle && styleSectionsEqual(oldStyle, item);
if (metaEqual && codeEqual) {
stats.unchanged.names.push(oldStyle.name);
stats.unchanged.ids.push(oldStyle.id);
continue;
}
saveStyle(item, {notify: false}).then(style => {
handleUpdate(style, {reason: 'import'});
setTimeout(proceed, 0);
setTimeout(proceed, 0, resolve);
if (!oldStyle) {
stats.added.names.push(style.name);
stats.added.ids.push(style.id);
}
else if (!metaEqual && !codeEqual) {
stats.metaAndCode.names.push(reportNameChange(oldStyle, style));
stats.metaAndCode.ids.push(style.id);
}
else if (!codeEqual) {
stats.codeOnly.names.push(style.name);
stats.codeOnly.ids.push(style.id);
}
else {
stats.metaOnly.names.push(reportNameChange(oldStyle, style));
stats.metaOnly.ids.push(style.id);
}
});
} else {
refreshAllTabs().then(() => {
return;
}
done(resolve);
}
function done(resolve) {
const numChanged = stats.metaAndCode.names.length +
stats.metaOnly.names.length +
stats.codeOnly.names.length +
stats.added.names.length;
Promise.resolve(numChanged && refreshAllTabs()).then(() => {
scrollTo(0, 0);
setTimeout(alert, 100, numStyles + ' styles installed/updated');
resolve(numStyles);
const report = Object.keys(stats)
.filter(kind => stats[kind].names.length)
.map(kind => `<details data-id="${kind}">
<summary><b>${stats[kind].names.length} ${stats[kind].legend}</b></summary>
<small>` + stats[kind].names.map((name, i) =>
`<div data-id="${stats[kind].ids[i]}">${name}</div>`).join('') + `
</small>
</details>`)
.join('');
const box = messageBox({
title: 'Finished importing styles',
contents: report || 'Nothing was changed.',
buttons: [t('confirmOK'), numChanged && t('undo')],
onclick: btnIndex => btnIndex == 1 && undo(),
});
}
}
bindClick(box);
resolve(numChanged);
});
}
function generateFileName() {
var today = new Date();
var dd = '0' + today.getDate();
var mm = '0' + (today.getMonth() + 1);
var yyyy = today.getFullYear();
function undo() {
const oldStylesById = new Map(oldStyles.map(style => [style.id, style]));
const newIds = [
...stats.metaAndCode.ids,
...stats.metaOnly.ids,
...stats.codeOnly.ids,
...stats.added.ids,
];
index = 0;
return new Promise(undoNextId)
.then(refreshAllTabs)
.then(() => messageBox({
title: 'Import has been undone',
contents: newIds.length + ' styles were reverted.',
buttons: [t('confirmOK')],
}));
function undoNextId(resolve) {
if (index == newIds.length) {
resolve();
return;
}
const id = newIds[index++];
deleteStyle(id, {notify: false}).then(id => {
handleDelete(id);
const oldStyle = oldStylesById.get(id);
if (oldStyle) {
saveStyle(Object.assign(oldStyle, {reason: 'undoImport'}), {notify: false})
.then(handleUpdate)
.then(() => setTimeout(undoNextId, 0, resolve));
} else {
setTimeout(undoNextId, 0, resolve);
}
});
}
}
dd = dd.substr(-2);
mm = mm.substr(-2);
function bindClick(box) {
for (let block of [...box.querySelectorAll('details')]) {
if (block.dataset.id != 'invalid') {
block.style.cursor = 'pointer';
block.onclick = event => {
const styleElement = $(`[style-id="${event.target.dataset.id}"]`);
if (styleElement) {
scrollElementIntoView(styleElement);
highlightElement(styleElement);
}
};
}
}
}
today = mm + '-' + dd + '-' + yyyy;
function deepCopyStyles() {
const clonedStyles = [];
for (let style of cachedStyles.list || []) {
style = Object.assign({}, style);
style.sections = style.sections.slice();
for (let i = 0, section; (section = style.sections[i]); i++) {
const copy = style.sections[i] = Object.assign({}, section);
for (let propName in copy) {
const prop = copy[propName];
if (prop instanceof Array) {
copy[propName] = prop.slice();
}
}
}
clonedStyles.push(style);
}
return clonedStyles;
}
return 'stylus-' + today + STYLISH_DUMPFILE_EXTENSION;
function limitString(s, limit = 100) {
return s.length <= limit ? s : s.substr(0, limit) + '...';
}
function reportNameChange(oldStyle, newStyle) {
return newStyle.name != oldStyle.name
? oldStyle.name + ' —> ' + newStyle.name
: oldStyle.name;
}
}
document.getElementById('file-all-styles').onclick = () => {
getStyles({}, function (styles) {
let text = JSON.stringify(styles, null, '\t');
let fileName = generateFileName() || STYLISH_DEFAULT_SAVE_NAME;
const text = JSON.stringify(styles, null, '\t');
const fileName = generateFileName();
let url = 'data:text/plain;charset=utf-8,' + encodeURIComponent(text);
const url = 'data:text/plain;charset=utf-8,' + encodeURIComponent(text);
// for long URLs; https://github.com/schomery/stylish-chrome/issues/13#issuecomment-284582600
fetch(url)
.then(res => res.blob())
@ -103,46 +234,51 @@ document.getElementById('file-all-styles').onclick = () => {
a.dispatchEvent(new MouseEvent('click'));
});
});
function generateFileName() {
const today = new Date();
const dd = ('0' + today.getDate()).substr(-2);
const mm = ('0' + (today.getMonth() + 1)).substr(-2);
const yyyy = today.getFullYear();
return `stylus-${mm}-${dd}-${yyyy}${STYLUS_BACKUP_FILE_EXT}`;
}
};
document.getElementById('unfile-all-styles').onclick = () => {
importFromFile({fileTypeFilter: STYLISH_DUMPFILE_EXTENSION});
importFromFile({fileTypeFilter: STYLUS_BACKUP_FILE_EXT});
};
const dropTarget = Object.assign(document.body, {
ondragover: event => {
Object.assign(document.body, {
ondragover(event) {
const hasFiles = event.dataTransfer.types.includes('Files');
event.dataTransfer.dropEffect = hasFiles || event.target.type == 'search' ? 'copy' : 'none';
dropTarget.classList.toggle('dropzone', hasFiles);
this.classList.toggle('dropzone', hasFiles);
if (hasFiles) {
event.preventDefault();
clearTimeout(dropTarget.fadeoutTimer);
dropTarget.classList.remove('fadeout');
clearTimeout(this.fadeoutTimer);
this.classList.remove('fadeout');
}
},
ondragend: event => {
dropTarget.classList.add('fadeout');
// transitionend event may not fire if the user switched to another tab so we'll use a timer
clearTimeout(dropTarget.fadeoutTimer);
dropTarget.fadeoutTimer = setTimeout(() => {
dropTarget.classList.remove('dropzone', 'fadeout');
}, 250);
ondragend(event) {
this.classList.add('fadeout');
this.addEventListener('animationend', function _() {
this.removeEventListener('animationend', _);
this.style.animationDuration = '';
this.classList.remove('dropzone', 'fadeout');
});
},
ondragleave: event => {
ondragleave(event) {
// Chrome sets screen coords to 0 on Escape key pressed or mouse out of document bounds
if (!event.screenX && !event.screenX) {
dropTarget.ondragend();
this.ondragend();
}
},
ondrop: event => {
ondrop(event) {
this.ondragend();
if (event.dataTransfer.files.length) {
event.preventDefault();
importFromFile({file: event.dataTransfer.files[0]}).then(() => {
dropTarget.classList.remove('dropzone');
});
} else {
dropTarget.ondragend();
importFromFile({file: event.dataTransfer.files[0]});
}
},
});

View File

@ -111,9 +111,12 @@ a.homepage {
display: inline;
}
.applies-to-extra summary {
summary {
font-weight: bold;
cursor: pointer;
}
.applies-to-extra summary {
list-style-type: none; /* for FF, allegedly */
}
@ -255,6 +258,19 @@ fieldset {
animation-fill-mode: both;
}
/* post-import report */
#message-box details:not([data-id="invalid"]) div:hover {
background-color: rgba(128, 128, 128, .3);
}
#message-box details:not(:last-child) {
margin-bottom: 1em;
}
#message-box details small div {
margin-left: 1.5em;
}
@keyframes fadein {
from {
opacity: 0;

View File

@ -4,6 +4,7 @@
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<title i18n-text="manageTitle"></title>
<link href="manage.css" rel="stylesheet">
<link href="msgbox/msgbox.css" rel="stylesheet">
<template data-id="style">
<div class="entry">
@ -121,8 +122,8 @@
</svg>
<script src="manage.js"></script>
<script src="openOptions.js"></script>
<script src="backup/fileSaveLoad.js"></script>
</body>
<script src="msgbox/msgbox.js"></script>
</body>
</html>

View File

@ -28,6 +28,11 @@ function initGlobalEvents() {
$('#check-all-updates').onclick = checkUpdateAll;
$('#apply-all-updates').onclick = applyUpdateAll;
$('#search').oninput = searchStyles;
$('#manage-options-button').onclick = () => chrome.runtime.openOptionsPage();
$('#manage-shortcuts-button').onclick = configureCommands.open;
$('#editor-styles-button').onclick = () => openURL({
url: 'https://userstyles.org/styles/browse/chrome-extension',
});
// focus search field on / key
document.onkeypress = event => {
@ -233,11 +238,7 @@ class EntryOnClick {
function handleUpdate(style, {reason} = {}) {
const element = createStyleElement(style);
const oldElement = $(`[style-id="${style.id}"]`, installed);
element.addEventListener('animationend', function _() {
element.removeEventListener('animationend', _);
element.classList.remove('highlight');
});
element.classList.add('highlight');
highlightElement(element);
if (!oldElement) {
installed.appendChild(element);
} else {
@ -247,11 +248,7 @@ function handleUpdate(style, {reason} = {}) {
$('.update-note', element).innerHTML = t('updateCompleted');
}
}
// align to the top/bottom of the visible area if wasn't visible
const bounds = element.getBoundingClientRect();
if (bounds.top < 0 || bounds.top > innerHeight - bounds.height) {
element.scrollIntoView(bounds.top < 0 );
}
scrollElementIntoView(element);
}
@ -466,6 +463,24 @@ function getClickedStyleElement(event) {
}
function scrollElementIntoView(element) {
// align to the top/bottom of the visible area if wasn't visible
const bounds = element.getBoundingClientRect();
if (bounds.top < 0 || bounds.top > innerHeight - bounds.height) {
element.scrollIntoView(bounds.top < 0);
}
}
function highlightElement(element) {
element.addEventListener('animationend', function _() {
element.removeEventListener('animationend', _);
element.classList.remove('highlight');
});
element.classList.add('highlight');
}
function rememberScrollPosition() {
history.replaceState({scrollY}, document.title);
}

102
msgbox/msgbox.css Normal file
View File

@ -0,0 +1,102 @@
#message-box {
top: 3rem;
right: 3rem;
min-width: 10rem;
max-width: 50vw;
min-height: 5rem;
max-height: 90vh;
position: fixed;
display: flex;
box-shadow: 5px 5px 50px rgba(0, 0, 0, 0.35);
animation: fadein .25s ease-in-out;
flex-direction: column;
z-index: 9999990;
}
#message-box > * {
z-index: 9999991;
background-color: white;
}
#message-box.fadeout {
animation: fadeout .5s ease-in-out;
}
#message-box::before {
position: fixed;
content: "";
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .25);
}
#message-box.big {
max-width: none;
left: 3rem;
}
#message-box-title {
font-weight: bold;
background-color: rgb(145, 208, 198);
padding: .75rem 50px .75rem .75rem;
font-size: 1rem;
}
#message-box-title::before {
content: "";
width: 0;
height: 0;
padding: 0 32px 32px 0;
background: url(/32.png);
display: inline-block;
vertical-align: middle;
margin-right: .5rem;
}
#message-box-close-icon {
cursor: pointer;
width: 8px;
height: 8px;
border: 8px solid transparent;
position: absolute;
right: 0;
top: 0;
background: linear-gradient(-45deg, transparent 5px, black 5px, black 6px, transparent 6.5px) no-repeat, linear-gradient(45deg, transparent 5px, black 5px, black 6px, transparent 6.5px) no-repeat;
}
#message-box-contents {
overflow: auto;
padding: .75rem;
position: relative;
flex-grow: 9;
}
#message-box-buttons {
padding: .75rem;
background-color: #f0f0f0;
}
#message-box-buttons button:not(:last-child) {
margin-right: 1em;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

64
msgbox/msgbox.js Normal file
View File

@ -0,0 +1,64 @@
'use strict';
function messageBox({title, contents, buttons, onclick}) {
// keep the same reference to be able to remove the listener later
messageBox.close = messageBox.close || close;
if (messageBox.element) {
messageBox.element.remove();
}
const id = 'message-box';
const putAs = typeof contents == 'string' ? 'innerHTML' : 'appendChild';
messageBox.element = $element({id, appendChild: [
$element({id: `${id}-title`, innerHTML: title}),
$element({id: `${id}-close-icon`, onclick: messageBox.close}),
$element({id: `${id}-contents`, [putAs]: contents}),
$element({id: `${id}-buttons`,
onclick: relayButtonClick,
appendChild: (buttons || []).map(textContent =>
textContent && $element({tag: 'button', textContent}))
}),
]});
show();
return messageBox.element;
function show() {
document.body.appendChild(messageBox.element);
document.addEventListener('keydown', messageBox.close);
}
function close(event) {
if ((!event
|| event.type == 'click'
|| event.keyCode == 27 && !event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey)
&& messageBox.element) {
const box = messageBox.element;
box.classList.add('fadeout');
box.addEventListener('animationend', function _() {
box.removeEventListener('animationend', _);
box.remove();
});
document.removeEventListener('keydown', messageBox.close);
$(`#${id}-buttons`).onclick = null;
messageBox.element = null;
}
}
function relayButtonClick(event) {
const button = event.target.closest('button');
if (button) {
close();
if (onclick) {
onclick([...this.children].indexOf(button));
}
}
}
function $element(opt) {
const element = document.createElement(opt.tag || 'div');
(opt.appendChild instanceof Array ? opt.appendChild : [opt.appendChild])
.forEach(child => child && element.appendChild(child));
delete opt.appendChild;
delete opt.tag;
return Object.assign(element, opt);
}
}

View File

@ -1,20 +0,0 @@
/* globals configureCommands */
'use strict';
document.querySelector('#manage-options-button').addEventListener("click", function() {
if (chrome.runtime.openOptionsPage) {
// Supported (Chrome 42+)
chrome.runtime.openOptionsPage();
} else {
// Fallback
window.open(chrome.runtime.getURL('options/index.html'));
}
});
document.querySelector('#manage-shortcuts-button').addEventListener("click", configureCommands.open);
document.querySelector('#editor-styles-button').addEventListener("click", function() {
chrome.tabs.create({
'url': 'https://userstyles.org/styles/browse/chrome-extension'
});
});

View File

@ -256,23 +256,28 @@ function saveStyle(style, {notify = true} = {}) {
const tx = db.transaction(['styles'], 'readwrite');
const os = tx.objectStore('styles');
const id = style.id !== undefined && style.id !== null ? Number(style.id) : null;
const reason = style.reason;
delete style.method;
delete style.reason;
// Update
if (style.id) {
style.id = Number(style.id);
os.get(style.id).onsuccess = eventGet => {
if (id != null) {
style.id = id;
os.get(id).onsuccess = eventGet => {
const existed = !!eventGet.target.result;
const oldStyle = Object.assign({}, eventGet.target.result);
const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle);
style = Object.assign(oldStyle, style);
addMissingStyleTargets(style);
os.put(style).onsuccess = eventPut => {
style.id = style.id || eventPut.target.result;
invalidateCache(notify, {updated: style});
invalidateCache(notify, existed ? {updated: style} : {added: style});
if (notify) {
notifyAllTabs({method: 'styleUpdated', style, codeIsUpdated, reason});
notifyAllTabs({
method: existed ? 'styleUpdated' : 'styleAdded',
style, codeIsUpdated, reason,
});
}
resolve(style);
};
@ -294,8 +299,10 @@ function saveStyle(style, {notify = true} = {}) {
os.add(style).onsuccess = event => {
// Give it the ID that was generated
style.id = event.target.result;
invalidateCache(true, {added: style});
invalidateCache(notify, {added: style});
if (notify) {
notifyAllTabs({method: 'styleAdded', style, reason});
}
resolve(style);
};
});
@ -320,14 +327,16 @@ function enableStyle(id, enabled) {
}
function deleteStyle(id) {
function deleteStyle(id, {notify = true} = {}) {
return new Promise(resolve =>
getDatabase(db => {
const tx = db.transaction(['styles'], 'readwrite');
const os = tx.objectStore('styles');
os.delete(Number(id)).onsuccess = event => {
invalidateCache(true, {deletedId: id});
invalidateCache(notify, {deletedId: id});
if (notify) {
notifyAllTabs({method: 'styleDeleted', id});
}
resolve(id);
};
}));