diff --git a/manage.html b/manage.html
index 8dbdb2bc..233e667a 100644
--- a/manage.html
+++ b/manage.html
@@ -126,6 +126,9 @@
+
+
+
diff --git a/manage/filters.js b/manage/filters.js
new file mode 100644
index 00000000..1bb4912f
--- /dev/null
+++ b/manage/filters.js
@@ -0,0 +1,319 @@
+/* global installed */
+'use strict';
+
+const filtersSelector = {
+ hide: '',
+ unhide: '',
+ numShown: 0,
+ numTotal: 0,
+};
+
+
+onDOMready().then(() => {
+ $('#search').oninput = searchStyles;
+
+ $$('[data-filter]').forEach(el => {
+ el.onchange = filterOnChange;
+ if (el.closest('.hidden')) {
+ el.checked = false;
+ }
+ });
+
+ // let manage::initGlobalEvents run onDOMready first
+ debounce(filterOnChange, 0, {forceRefilter: true});
+});
+
+
+function filterOnChange({target: el, forceRefilter}) {
+ const getValue = el => (el.type === 'checkbox' ? el.checked : el.value.trim());
+ if (!forceRefilter) {
+ const value = getValue(el);
+ if (value === el.lastValue) {
+ return;
+ }
+ el.lastValue = value;
+ }
+ const enabledFilters = $$('#header [data-filter]').filter(el => getValue(el));
+ const buildFilter = hide =>
+ (hide ? '' : '.entry.hidden') +
+ [...enabledFilters.map(el =>
+ el.dataset[hide ? 'filterHide' : 'filter']
+ .split(/,\s*/)
+ .map(s => (hide ? '.entry:not(.hidden)' : '') + s)
+ .join(','))
+ ].join(hide ? ',' : '');
+ Object.assign(filtersSelector, {
+ hide: buildFilter(true),
+ unhide: buildFilter(false),
+ });
+ reapplyFilter();
+}
+
+
+function filterAndAppend({entry, container}) {
+ if (!container) {
+ container = [entry];
+ // reverse the visibility, otherwise reapplyFilter will see no need to work
+ if (!filtersSelector.hide || !entry.matches(filtersSelector.hide)) {
+ entry.classList.add('hidden');
+ }
+ } else if ($('#search').value.trim()) {
+ searchStyles({immediately: true, container});
+ }
+ reapplyFilter(container);
+}
+
+
+function reapplyFilter(container = installed) {
+ // A: show
+ let toHide = [];
+ let toUnhide = [];
+ if (filtersSelector.hide) {
+ filterContainer({hide: false});
+ } else {
+ toUnhide = container;
+ }
+ // showStyles() is building the page and no filters are active
+ if (toUnhide instanceof DocumentFragment) {
+ installed.appendChild(toUnhide);
+ return;
+ } else if (toUnhide.length && $('#search').value.trim()) {
+ searchStyles({immediately: true, container: toUnhide});
+ filterContainer({hide: false});
+ }
+ // filtering needed or a single-element job from handleUpdate()
+ const entries = installed.children;
+ const numEntries = entries.length;
+ let numVisible = numEntries - $$('.entry.hidden').length;
+ for (const entry of toUnhide.children || toUnhide) {
+ const next = findInsertionPoint(entry);
+ if (entry.nextElementSibling !== next) {
+ installed.insertBefore(entry, next);
+ }
+ if (entry.classList.contains('hidden')) {
+ entry.classList.remove('hidden');
+ numVisible++;
+ }
+ }
+ // B: hide
+ if (filtersSelector.hide) {
+ filterContainer({hide: true});
+ }
+ if (!toHide.length) {
+ showFiltersStats();
+ return;
+ }
+ for (const entry of toHide) {
+ entry.classList.add('hidden');
+ }
+ // showStyles() is building the page with filters active so we need to:
+ // 1. add all hidden entries to the end
+ // 2. add the visible entries before the first hidden entry
+ if (container instanceof DocumentFragment) {
+ for (const entry of toHide) {
+ installed.appendChild(entry);
+ }
+ installed.insertBefore(container, $('.entry.hidden'));
+ showFiltersStats();
+ return;
+ }
+ // normal filtering of the page or a single-element job from handleUpdate()
+ // we need to keep the visible entries together at the start
+ // first pass only moves one hidden entry in hidden groups with odd number of items
+ shuffle(false);
+ setTimeout(shuffle, 0, true);
+ // single-element job from handleEvent(): add the last wraith
+ if (toHide.length === 1 && toHide[0].parentElement !== installed) {
+ installed.appendChild(toHide[0]);
+ }
+ showFiltersStats();
+ return;
+
+ /***************************************/
+
+ function filterContainer({hide}) {
+ const selector = filtersSelector[hide ? 'hide' : 'unhide'];
+ if (container.filter) {
+ if (hide) {
+ // already filtered in previous invocation
+ return;
+ }
+ for (const el of container) {
+ (el.matches(selector) ? toUnhide : toHide).push(el);
+ }
+ return;
+ } else if (hide) {
+ toHide = $$(selector, container);
+ } else {
+ toUnhide = $$(selector, container);
+ }
+ }
+
+ function shuffle(fullPass) {
+ if (fullPass && !document.body.classList.contains('update-in-progress')) {
+ $('#check-all-updates').disabled = !$('.updatable:not(.can-update)');
+ }
+ // 1. skip the visible group on top
+ let firstHidden = $('#installed > .hidden');
+ let entry = firstHidden;
+ let i = [...entries].indexOf(entry);
+ let horizon = entries[numVisible];
+ const skipGroup = state => {
+ const start = i;
+ const first = entry;
+ while (entry && entry.classList.contains('hidden') === state) {
+ entry = entry.nextElementSibling;
+ i++;
+ }
+ return {first, start, len: i - start};
+ };
+ let prevGroup = i ? {first: entries[0], start: 0, len: i} : skipGroup(true);
+ // eslint-disable-next-line no-unmodified-loop-condition
+ while (entry) {
+ // 2a. find the next hidden group's start and end
+ // 2b. find the next visible group's start and end
+ const isHidden = entry.classList.contains('hidden');
+ const group = skipGroup(isHidden);
+ const hidden = isHidden ? group : prevGroup;
+ const visible = isHidden ? prevGroup : group;
+ // 3. move the shortest group; repeat 2-3
+ if (hidden.len < visible.len && (fullPass || hidden.len % 2)) {
+ // 3a. move hidden under the horizon
+ for (let j = 0; j < (fullPass ? hidden.len : 1); j++) {
+ const entry = entries[hidden.start];
+ installed.insertBefore(entry, horizon);
+ horizon = entry;
+ i--;
+ }
+ prevGroup = isHidden ? skipGroup(false) : group;
+ firstHidden = entry;
+ } else if (isHidden || !fullPass) {
+ prevGroup = group;
+ } else {
+ // 3b. move visible above the horizon
+ for (let j = 0; j < visible.len; j++) {
+ const entry = entries[visible.start + j];
+ installed.insertBefore(entry, firstHidden);
+ }
+ prevGroup = {
+ first: firstHidden,
+ start: hidden.start + visible.len,
+ len: hidden.len + skipGroup(true).len,
+ };
+ }
+ }
+ }
+
+ function findInsertionPoint(entry) {
+ const nameLLC = entry.styleNameLowerCase;
+ let a = 0;
+ let b = Math.min(numEntries, numVisible) - 1;
+ if (b < 0) {
+ return entries[numVisible];
+ }
+ if (entries[0].styleNameLowerCase > nameLLC) {
+ return entries[0];
+ }
+ if (entries[b].styleNameLowerCase <= nameLLC) {
+ return entries[numVisible];
+ }
+ // bisect
+ while (a < b - 1) {
+ const c = (a + b) / 2 | 0;
+ if (nameLLC < entries[c].styleNameLowerCase) {
+ b = c;
+ } else {
+ a = c;
+ }
+ }
+ if (entries[a].styleNameLowerCase > nameLLC) {
+ return entries[a];
+ }
+ while (a <= b && entries[a].styleNameLowerCase < nameLLC) {
+ a++;
+ }
+ return entries[entries[a].styleNameLowerCase <= nameLLC ? a + 1 : a];
+ }
+}
+
+
+function showFiltersStats({immediately} = {}) {
+ if (!immediately) {
+ debounce(showFiltersStats, 100, {immediately: true});
+ return;
+ }
+ $('#filters').classList.toggle('active', filtersSelector.hide !== '');
+ const numTotal = BG.cachedStyles.list.length;
+ const numHidden = installed.getElementsByClassName('entry hidden').length;
+ const numShown = Math.min(numTotal - numHidden, installed.children.length);
+ if (filtersSelector.numShown !== numShown ||
+ filtersSelector.numTotal !== numTotal) {
+ filtersSelector.numShown = numShown;
+ filtersSelector.numTotal = numTotal;
+ $('#filters-stats').textContent = t('filteredStyles', [numShown, numTotal]);
+ }
+}
+
+
+function searchStyles({immediately, container}) {
+ const searchElement = $('#search');
+ const query = searchElement.value.toLocaleLowerCase();
+ const queryPrev = searchElement.lastValue || '';
+ if (query === queryPrev && !immediately && !container) {
+ return;
+ }
+ if (!immediately) {
+ debounce(searchStyles, 150, {immediately: true});
+ return;
+ }
+ searchElement.lastValue = query;
+
+ const searchInVisible = queryPrev && query.includes(queryPrev);
+ const entries = container && container.children || container ||
+ (searchInVisible ? $$('.entry:not(.hidden)') : installed.children);
+ let needsRefilter = false;
+ for (const entry of entries) {
+ let isMatching = !query;
+ if (!isMatching) {
+ const style = BG.cachedStyles.byId.get(entry.styleId) || {};
+ isMatching = Boolean(style && (
+ isMatchingText(style.name) ||
+ style.url && isMatchingText(style.url) ||
+ isMatchingStyle(style)));
+ }
+ if (entry.classList.contains('not-matching') !== !isMatching) {
+ entry.classList.toggle('not-matching', !isMatching);
+ needsRefilter = true;
+ }
+ }
+ if (needsRefilter && !container) {
+ filterOnChange({forceRefilter: true});
+ }
+ return;
+
+ function isMatchingStyle(style) {
+ for (const section of style.sections) {
+ for (const prop in section) {
+ const value = section[prop];
+ switch (typeof value) {
+ case 'string':
+ if (isMatchingText(value)) {
+ return true;
+ }
+ break;
+ case 'object':
+ for (const str of value) {
+ if (isMatchingText(str)) {
+ return true;
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ function isMatchingText(text) {
+ return text.toLocaleLowerCase().indexOf(query) >= 0;
+ }
+}
diff --git a/manage/manage.js b/manage/manage.js
index fe16e4c0..cd7bffca 100644
--- a/manage/manage.js
+++ b/manage/manage.js
@@ -1,13 +1,10 @@
/* global messageBox, getStyleWithNoCode, retranslateCSS */
+/* global filtersSelector, filterAndAppend */
+/* global checkUpdate, handleUpdateInstalled */
+/* global objectDiff */
'use strict';
let installed;
-const filtersSelector = {
- hide: '',
- unhide: '',
- numShown: 0,
- numTotal: 0,
-};
const ENTRY_ID_PREFIX_RAW = 'style-';
const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
@@ -64,11 +61,6 @@ function onRuntimeMessage(msg) {
function initGlobalEvents() {
installed = $('#installed');
installed.onclick = handleEvent.entryClicked;
- $('#check-all-updates').onclick = checkUpdateAll;
- $('#check-all-updates-force').onclick = checkUpdateAll;
- $('#apply-all-updates').onclick = applyUpdateAll;
- $('#update-history').onclick = showUpdateHistory;
- $('#search').oninput = searchStyles;
$('#manage-options-button').onclick = () => chrome.runtime.openOptionsPage();
$('#manage-shortcuts-button').onclick = () => openURL({url: URLS.configureCommands});
$$('#header a[href^="http"]').forEach(a => (a.onclick = handleEvent.external));
@@ -98,14 +90,6 @@ function initGlobalEvents() {
// N.B. triggers existing onchange listeners
setupLivePrefs();
- $$('[data-filter]').forEach(el => {
- el.onchange = handleEvent.filterOnChange;
- if (el.closest('.hidden')) {
- el.checked = false;
- }
- });
- handleEvent.filterOnChange({forceRefilter: true});
-
$$('[id^="manage.newUI"]')
.forEach(el => (el.oninput = (el.onchange = switchUI)));
@@ -386,31 +370,6 @@ Object.assign(handleEvent, {
}
}
},
-
- filterOnChange({target: el, forceRefilter}) {
- const getValue = el => (el.type === 'checkbox' ? el.checked : el.value.trim());
- if (!forceRefilter) {
- const value = getValue(el);
- if (value === el.lastValue) {
- return;
- }
- el.lastValue = value;
- }
- const enabledFilters = $$('#header [data-filter]').filter(el => getValue(el));
- const buildFilter = hide =>
- (hide ? '' : '.entry.hidden') +
- [...enabledFilters.map(el =>
- el.dataset[hide ? 'filterHide' : 'filter']
- .split(/,\s*/)
- .map(s => (hide ? '.entry:not(.hidden)' : '') + s)
- .join(','))
- ].join(hide ? ',' : '');
- Object.assign(filtersSelector, {
- hide: buildFilter(true),
- unhide: buildFilter(false),
- });
- reapplyFilter();
- },
});
@@ -429,7 +388,7 @@ function handleUpdate(style, {reason, method} = {}) {
}
}
if (reason === 'update' && entry.matches('.updatable')) {
- handleUpdateInstalled();
+ handleUpdateInstalled(entry);
}
filterAndAppend({entry});
if (!entry.matches('.hidden') && reason !== 'import') {
@@ -454,13 +413,6 @@ function handleUpdate(style, {reason, method} = {}) {
oldEntry = null;
}
}
-
- function handleUpdateInstalled() {
- entry.classList.add('update-done');
- entry.classList.remove('can-update', 'updatable');
- $('.update-note', entry).textContent = t('updateCompleted');
- renderUpdatesOnlyFilter();
- }
}
@@ -547,484 +499,6 @@ function switchUI({styleOnly} = {}) {
}
-function applyUpdateAll() {
- const btnApply = $('#apply-all-updates');
- btnApply.disabled = true;
- setTimeout(() => {
- btnApply.classList.add('hidden');
- btnApply.disabled = false;
- renderUpdatesOnlyFilter({show: false});
- }, 1000);
-
- $$('.can-update .update').forEach(button => {
- scrollElementIntoView(button);
- button.click();
- });
-}
-
-
-function checkUpdateAll() {
- document.body.classList.add('update-in-progress');
- $('#check-all-updates').disabled = true;
- $('#check-all-updates-force').classList.add('hidden');
- $('#apply-all-updates').classList.add('hidden');
- $('#update-all-no-updates').classList.add('hidden');
-
- const ignoreDigest = this && this.id === 'check-all-updates-force';
- $$('.updatable:not(.can-update)' + (ignoreDigest ? '' : ':not(.update-problem)'))
- .map(el => checkUpdate(el, {single: false}));
-
- let total = 0;
- let checked = 0;
- let skippedEdited = 0;
- let updated = 0;
-
- BG.updater.checkAllStyles({observer, save: false, ignoreDigest}).then(done);
-
- function observer(state, value, details) {
- switch (state) {
- case BG.updater.COUNT:
- total = value;
- break;
- case BG.updater.UPDATED:
- if (++updated === 1) {
- $('#apply-all-updates').disabled = true;
- $('#apply-all-updates').classList.remove('hidden');
- }
- $('#apply-all-updates').dataset.value = updated;
- // fallthrough
- case BG.updater.SKIPPED:
- checked++;
- if (details === BG.updater.EDITED || details === BG.updater.MAYBE_EDITED) {
- skippedEdited++;
- }
- reportUpdateState(state, value, details);
- break;
- }
- const progress = $('#update-progress');
- const maxWidth = progress.parentElement.clientWidth;
- progress.style.width = Math.round(checked / total * maxWidth) + 'px';
- }
-
- function done() {
- document.body.classList.remove('update-in-progress');
- $('#check-all-updates').disabled = total === 0;
- $('#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');
- $('#check-all-updates-force').classList.toggle('hidden', skippedEdited === 0);
- }
- }
-}
-
-
-function checkUpdate(entry, {single = true} = {}) {
- $('.update-note', entry).textContent = t('checkingForUpdate');
- $('.check-update', entry).title = '';
- if (single) {
- 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');
-}
-
-
-function reportUpdateState(state, style, details) {
- const entry = $(ENTRY_ID_PREFIX + style.id);
- entry.classList.remove('checking-update');
- switch (state) {
- case BG.updater.UPDATED:
- entry.classList.add('can-update');
- entry.updatedCode = style;
- $('.update-note', entry).textContent = '';
- $('#onlyUpdates').classList.remove('hidden');
- break;
- case BG.updater.SKIPPED: {
- if (entry.classList.contains('can-update')) {
- break;
- }
- const same = details === BG.updater.SAME_MD5 || details === BG.updater.SAME_CODE;
- const edited = details === BG.updater.EDITED || details === BG.updater.MAYBE_EDITED;
- entry.dataset.details = details;
- if (!details) {
- details = t('updateCheckFailServerUnreachable');
- } else if (typeof details === 'number') {
- details = t('updateCheckFailBadResponseCode', [details]);
- } else if (details === BG.updater.EDITED) {
- details = t('updateCheckSkippedLocallyEdited') + '\n' + t('updateCheckManualUpdateHint');
- } else if (details === BG.updater.MAYBE_EDITED) {
- details = t('updateCheckSkippedMaybeLocallyEdited') + '\n' + t('updateCheckManualUpdateHint');
- }
- const message = same ? t('updateCheckSucceededNoUpdate') : details;
- entry.classList.add('no-update');
- entry.classList.toggle('update-problem', !same);
- $('.update-note', entry).textContent = message;
- $('.check-update', entry).title = newUI.enabled ? message : '';
- $('.update', entry).title = t(edited ? 'updateCheckManualUpdateForce' : 'installUpdate');
- if (!document.body.classList.contains('update-in-progress')) {
- // this is a single update job so we can decide whether to hide the filter
- renderUpdatesOnlyFilter({show: $('.can-update, .update-problem')});
- }
- }
- }
- if (filtersSelector.hide) {
- filterAndAppend({entry});
- }
-}
-
-
-function renderUpdatesOnlyFilter({show, check} = {}) {
- const numUpdatable = $$('.can-update').length;
- const mightUpdate = numUpdatable > 0 || $('.update-problem');
- const checkbox = $('#onlyUpdates input');
- show = show !== undefined ? show : mightUpdate;
- check = check !== undefined ? show && check : checkbox.checked && mightUpdate;
-
- $('#onlyUpdates').classList.toggle('hidden', !show);
- checkbox.checked = check;
- checkbox.dispatchEvent(new Event('change'));
-
- const btnApply = $('#apply-all-updates');
- if (!btnApply.matches('.hidden')) {
- if (numUpdatable > 0) {
- btnApply.dataset.value = numUpdatable;
- } else {
- btnApply.classList.add('hidden');
- }
- }
-}
-
-
-function showUpdateHistory() {
- BG.chromeLocal.getValue('updateLog').then((lines = []) => {
- messageBox({
- title: t('updateCheckHistory'),
- contents: $element({
- className: 'update-history-log',
- textContent: lines.join('\n'),
- }),
- buttons: [t('confirmOK')],
- onshow: () => ($('#message-box-contents').scrollTop = 1e9),
- });
- });
-}
-
-
-function searchStyles({immediately, container}) {
- const searchElement = $('#search');
- const query = searchElement.value.toLocaleLowerCase();
- const queryPrev = searchElement.lastValue || '';
- if (query === queryPrev && !immediately && !container) {
- return;
- }
- if (!immediately) {
- debounce(searchStyles, 150, {immediately: true});
- return;
- }
- searchElement.lastValue = query;
-
- const searchInVisible = queryPrev && query.includes(queryPrev);
- const entries = container && container.children || container ||
- (searchInVisible ? $$('.entry:not(.hidden)') : installed.children);
- let needsRefilter = false;
- for (const entry of entries) {
- let isMatching = !query;
- if (!isMatching) {
- const style = BG.cachedStyles.byId.get(entry.styleId) || {};
- isMatching = Boolean(style && (
- isMatchingText(style.name) ||
- style.url && isMatchingText(style.url) ||
- isMatchingStyle(style)));
- }
- if (entry.classList.contains('not-matching') !== !isMatching) {
- entry.classList.toggle('not-matching', !isMatching);
- needsRefilter = true;
- }
- }
- if (needsRefilter && !container) {
- handleEvent.filterOnChange({forceRefilter: true});
- }
- return;
-
- function isMatchingStyle(style) {
- for (const section of style.sections) {
- for (const prop in section) {
- const value = section[prop];
- switch (typeof value) {
- case 'string':
- if (isMatchingText(value)) {
- return true;
- }
- break;
- case 'object':
- for (const str of value) {
- if (isMatchingText(str)) {
- return true;
- }
- }
- break;
- }
- }
- }
- }
-
- function isMatchingText(text) {
- return text.toLocaleLowerCase().indexOf(query) >= 0;
- }
-}
-
-
-function filterAndAppend({entry, container}) {
- if (!container) {
- container = [entry];
- // reverse the visibility, otherwise reapplyFilter will see no need to work
- if (!filtersSelector.hide || !entry.matches(filtersSelector.hide)) {
- entry.classList.add('hidden');
- }
- } else if ($('#search').value.trim()) {
- searchStyles({immediately: true, container});
- }
- reapplyFilter(container);
-}
-
-
-function reapplyFilter(container = installed) {
- // A: show
- let toHide = [];
- let toUnhide = [];
- if (filtersSelector.hide) {
- filterContainer({hide: false});
- } else {
- toUnhide = container;
- }
- // showStyles() is building the page and no filters are active
- if (toUnhide instanceof DocumentFragment) {
- installed.appendChild(toUnhide);
- return;
- } else if (toUnhide.length && $('#search').value.trim()) {
- searchStyles({immediately: true, container: toUnhide});
- filterContainer({hide: false});
- }
- // filtering needed or a single-element job from handleUpdate()
- const entries = installed.children;
- const numEntries = entries.length;
- let numVisible = numEntries - $$('.entry.hidden').length;
- for (const entry of toUnhide.children || toUnhide) {
- const next = findInsertionPoint(entry);
- if (entry.nextElementSibling !== next) {
- installed.insertBefore(entry, next);
- }
- if (entry.classList.contains('hidden')) {
- entry.classList.remove('hidden');
- numVisible++;
- }
- }
- // B: hide
- if (filtersSelector.hide) {
- filterContainer({hide: true});
- }
- if (!toHide.length) {
- showFiltersStats();
- return;
- }
- for (const entry of toHide) {
- entry.classList.add('hidden');
- }
- // showStyles() is building the page with filters active so we need to:
- // 1. add all hidden entries to the end
- // 2. add the visible entries before the first hidden entry
- if (container instanceof DocumentFragment) {
- for (const entry of toHide) {
- installed.appendChild(entry);
- }
- installed.insertBefore(container, $('.entry.hidden'));
- showFiltersStats();
- return;
- }
- // normal filtering of the page or a single-element job from handleUpdate()
- // we need to keep the visible entries together at the start
- // first pass only moves one hidden entry in hidden groups with odd number of items
- shuffle(false);
- setTimeout(shuffle, 0, true);
- // single-element job from handleEvent(): add the last wraith
- if (toHide.length === 1 && toHide[0].parentElement !== installed) {
- installed.appendChild(toHide[0]);
- }
- showFiltersStats();
- return;
-
- /***************************************/
-
- function filterContainer({hide}) {
- const selector = filtersSelector[hide ? 'hide' : 'unhide'];
- if (container.filter) {
- if (hide) {
- // already filtered in previous invocation
- return;
- }
- for (const el of container) {
- (el.matches(selector) ? toUnhide : toHide).push(el);
- }
- return;
- } else if (hide) {
- toHide = $$(selector, container);
- } else {
- toUnhide = $$(selector, container);
- }
- }
-
- function shuffle(fullPass) {
- if (fullPass && !document.body.classList.contains('update-in-progress')) {
- $('#check-all-updates').disabled = !$('.updatable:not(.can-update)');
- }
- // 1. skip the visible group on top
- let firstHidden = $('#installed > .hidden');
- let entry = firstHidden;
- let i = [...entries].indexOf(entry);
- let horizon = entries[numVisible];
- const skipGroup = state => {
- const start = i;
- const first = entry;
- while (entry && entry.classList.contains('hidden') === state) {
- entry = entry.nextElementSibling;
- i++;
- }
- return {first, start, len: i - start};
- };
- let prevGroup = i ? {first: entries[0], start: 0, len: i} : skipGroup(true);
- // eslint-disable-next-line no-unmodified-loop-condition
- while (entry) {
- // 2a. find the next hidden group's start and end
- // 2b. find the next visible group's start and end
- const isHidden = entry.classList.contains('hidden');
- const group = skipGroup(isHidden);
- const hidden = isHidden ? group : prevGroup;
- const visible = isHidden ? prevGroup : group;
- // 3. move the shortest group; repeat 2-3
- if (hidden.len < visible.len && (fullPass || hidden.len % 2)) {
- // 3a. move hidden under the horizon
- for (let j = 0; j < (fullPass ? hidden.len : 1); j++) {
- const entry = entries[hidden.start];
- installed.insertBefore(entry, horizon);
- horizon = entry;
- i--;
- }
- prevGroup = isHidden ? skipGroup(false) : group;
- firstHidden = entry;
- } else if (isHidden || !fullPass) {
- prevGroup = group;
- } else {
- // 3b. move visible above the horizon
- for (let j = 0; j < visible.len; j++) {
- const entry = entries[visible.start + j];
- installed.insertBefore(entry, firstHidden);
- }
- prevGroup = {
- first: firstHidden,
- start: hidden.start + visible.len,
- len: hidden.len + skipGroup(true).len,
- };
- }
- }
- }
-
- function findInsertionPoint(entry) {
- const nameLLC = entry.styleNameLowerCase;
- let a = 0;
- let b = Math.min(numEntries, numVisible) - 1;
- if (b < 0) {
- return entries[numVisible];
- }
- if (entries[0].styleNameLowerCase > nameLLC) {
- return entries[0];
- }
- if (entries[b].styleNameLowerCase <= nameLLC) {
- return entries[numVisible];
- }
- // bisect
- while (a < b - 1) {
- const c = (a + b) / 2 | 0;
- if (nameLLC < entries[c].styleNameLowerCase) {
- b = c;
- } else {
- a = c;
- }
- }
- if (entries[a].styleNameLowerCase > nameLLC) {
- return entries[a];
- }
- while (a <= b && entries[a].styleNameLowerCase < nameLLC) {
- a++;
- }
- return entries[entries[a].styleNameLowerCase <= nameLLC ? a + 1 : a];
- }
-}
-
-
-function showFiltersStats({immediately} = {}) {
- if (!immediately) {
- debounce(showFiltersStats, 100, {immediately: true});
- return;
- }
- $('#filters').classList.toggle('active', filtersSelector.hide !== '');
- const numTotal = BG.cachedStyles.list.length;
- const numHidden = installed.getElementsByClassName('entry hidden').length;
- const numShown = Math.min(numTotal - numHidden, installed.children.length);
- if (filtersSelector.numShown !== numShown ||
- filtersSelector.numTotal !== numTotal) {
- filtersSelector.numShown = numShown;
- filtersSelector.numTotal = numTotal;
- $('#filters-stats').textContent = t('filteredStyles', [numShown, numTotal]);
- }
-}
-
-
function rememberScrollPosition() {
history.replaceState({scrollY: window.scrollY}, document.title);
}
-
-
-function objectDiff(first, second, path = '') {
- const diff = [];
- for (const key in first) {
- const a = first[key];
- const b = second[key];
- if (a === b) {
- continue;
- }
- if (b === undefined) {
- diff.push({path, key, values: [a], type: 'removed'});
- continue;
- }
- if (a && typeof a.filter === 'function' && b && typeof b.filter === 'function') {
- if (
- a.length !== b.length ||
- a.some((el, i) => {
- const result = !el || typeof el !== 'object'
- ? el !== b[i]
- : objectDiff(el, b[i], path + key + '[' + i + '].').length;
- return result;
- })
- ) {
- diff.push({path, key, values: [a, b], type: 'changed'});
- }
- } else if (typeof a === 'object' && typeof b === 'object') {
- diff.push(...objectDiff(a, b, path + key + '.'));
- } else {
- diff.push({path, key, values: [a, b], type: 'changed'});
- }
- }
- for (const key in second) {
- if (!(key in first)) {
- diff.push({path, key, values: [second[key]], type: 'added'});
- }
- }
- return diff;
-}
diff --git a/manage/object-diff.js b/manage/object-diff.js
new file mode 100644
index 00000000..1c87f289
--- /dev/null
+++ b/manage/object-diff.js
@@ -0,0 +1,39 @@
+'use strict';
+
+function objectDiff(first, second, path = '') {
+ const diff = [];
+ for (const key in first) {
+ const a = first[key];
+ const b = second[key];
+ if (a === b) {
+ continue;
+ }
+ if (b === undefined) {
+ diff.push({path, key, values: [a], type: 'removed'});
+ continue;
+ }
+ if (a && typeof a.filter === 'function' && b && typeof b.filter === 'function') {
+ if (
+ a.length !== b.length ||
+ a.some((el, i) => {
+ const result = !el || typeof el !== 'object'
+ ? el !== b[i]
+ : objectDiff(el, b[i], path + key + '[' + i + '].').length;
+ return result;
+ })
+ ) {
+ diff.push({path, key, values: [a, b], type: 'changed'});
+ }
+ } else if (typeof a === 'object' && typeof b === 'object') {
+ diff.push(...objectDiff(a, b, path + key + '.'));
+ } else {
+ diff.push({path, key, values: [a, b], type: 'changed'});
+ }
+ }
+ for (const key in second) {
+ if (!(key in first)) {
+ diff.push({path, key, values: [second[key]], type: 'added'});
+ }
+ }
+ return diff;
+}
diff --git a/manage/updater-ui.js b/manage/updater-ui.js
new file mode 100644
index 00000000..a17a170b
--- /dev/null
+++ b/manage/updater-ui.js
@@ -0,0 +1,189 @@
+/* global messageBox */
+/* global ENTRY_ID_PREFIX, newUI */
+/* global filtersSelector, filterAndAppend */
+'use strict';
+
+onDOMready().then(() => {
+ $('#check-all-updates').onclick = checkUpdateAll;
+ $('#check-all-updates-force').onclick = checkUpdateAll;
+ $('#apply-all-updates').onclick = applyUpdateAll;
+ $('#update-history').onclick = showUpdateHistory;
+});
+
+
+function applyUpdateAll() {
+ const btnApply = $('#apply-all-updates');
+ btnApply.disabled = true;
+ setTimeout(() => {
+ btnApply.classList.add('hidden');
+ btnApply.disabled = false;
+ renderUpdatesOnlyFilter({show: false});
+ }, 1000);
+
+ $$('.can-update .update').forEach(button => {
+ scrollElementIntoView(button);
+ button.click();
+ });
+}
+
+
+function checkUpdateAll() {
+ document.body.classList.add('update-in-progress');
+ $('#check-all-updates').disabled = true;
+ $('#check-all-updates-force').classList.add('hidden');
+ $('#apply-all-updates').classList.add('hidden');
+ $('#update-all-no-updates').classList.add('hidden');
+
+ const ignoreDigest = this && this.id === 'check-all-updates-force';
+ $$('.updatable:not(.can-update)' + (ignoreDigest ? '' : ':not(.update-problem)'))
+ .map(el => checkUpdate(el, {single: false}));
+
+ let total = 0;
+ let checked = 0;
+ let skippedEdited = 0;
+ let updated = 0;
+
+ BG.updater.checkAllStyles({observer, save: false, ignoreDigest}).then(done);
+
+ function observer(state, value, details) {
+ switch (state) {
+ case BG.updater.COUNT:
+ total = value;
+ break;
+ case BG.updater.UPDATED:
+ if (++updated === 1) {
+ $('#apply-all-updates').disabled = true;
+ $('#apply-all-updates').classList.remove('hidden');
+ }
+ $('#apply-all-updates').dataset.value = updated;
+ // fallthrough
+ case BG.updater.SKIPPED:
+ checked++;
+ if (details === BG.updater.EDITED || details === BG.updater.MAYBE_EDITED) {
+ skippedEdited++;
+ }
+ reportUpdateState(state, value, details);
+ break;
+ }
+ const progress = $('#update-progress');
+ const maxWidth = progress.parentElement.clientWidth;
+ progress.style.width = Math.round(checked / total * maxWidth) + 'px';
+ }
+
+ function done() {
+ document.body.classList.remove('update-in-progress');
+ $('#check-all-updates').disabled = total === 0;
+ $('#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');
+ $('#check-all-updates-force').classList.toggle('hidden', skippedEdited === 0);
+ }
+ }
+}
+
+
+function checkUpdate(entry, {single = true} = {}) {
+ $('.update-note', entry).textContent = t('checkingForUpdate');
+ $('.check-update', entry).title = '';
+ if (single) {
+ 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');
+}
+
+
+function reportUpdateState(state, style, details) {
+ const entry = $(ENTRY_ID_PREFIX + style.id);
+ entry.classList.remove('checking-update');
+ switch (state) {
+ case BG.updater.UPDATED:
+ entry.classList.add('can-update');
+ entry.updatedCode = style;
+ $('.update-note', entry).textContent = '';
+ $('#onlyUpdates').classList.remove('hidden');
+ break;
+ case BG.updater.SKIPPED: {
+ if (entry.classList.contains('can-update')) {
+ break;
+ }
+ const same = details === BG.updater.SAME_MD5 || details === BG.updater.SAME_CODE;
+ const edited = details === BG.updater.EDITED || details === BG.updater.MAYBE_EDITED;
+ entry.dataset.details = details;
+ if (!details) {
+ details = t('updateCheckFailServerUnreachable');
+ } else if (typeof details === 'number') {
+ details = t('updateCheckFailBadResponseCode', [details]);
+ } else if (details === BG.updater.EDITED) {
+ details = t('updateCheckSkippedLocallyEdited') + '\n' + t('updateCheckManualUpdateHint');
+ } else if (details === BG.updater.MAYBE_EDITED) {
+ details = t('updateCheckSkippedMaybeLocallyEdited') + '\n' + t('updateCheckManualUpdateHint');
+ }
+ const message = same ? t('updateCheckSucceededNoUpdate') : details;
+ entry.classList.add('no-update');
+ entry.classList.toggle('update-problem', !same);
+ $('.update-note', entry).textContent = message;
+ $('.check-update', entry).title = newUI.enabled ? message : '';
+ $('.update', entry).title = t(edited ? 'updateCheckManualUpdateForce' : 'installUpdate');
+ if (!document.body.classList.contains('update-in-progress')) {
+ // this is a single update job so we can decide whether to hide the filter
+ renderUpdatesOnlyFilter({show: $('.can-update, .update-problem')});
+ }
+ }
+ }
+ if (filtersSelector.hide) {
+ filterAndAppend({entry});
+ }
+}
+
+
+function renderUpdatesOnlyFilter({show, check} = {}) {
+ const numUpdatable = $$('.can-update').length;
+ const mightUpdate = numUpdatable > 0 || $('.update-problem');
+ const checkbox = $('#onlyUpdates input');
+ show = show !== undefined ? show : mightUpdate;
+ check = check !== undefined ? show && check : checkbox.checked && mightUpdate;
+
+ $('#onlyUpdates').classList.toggle('hidden', !show);
+ checkbox.checked = check;
+ checkbox.dispatchEvent(new Event('change'));
+
+ const btnApply = $('#apply-all-updates');
+ if (!btnApply.matches('.hidden')) {
+ if (numUpdatable > 0) {
+ btnApply.dataset.value = numUpdatable;
+ } else {
+ btnApply.classList.add('hidden');
+ }
+ }
+}
+
+
+function showUpdateHistory() {
+ BG.chromeLocal.getValue('updateLog').then((lines = []) => {
+ messageBox({
+ title: t('updateCheckHistory'),
+ contents: $element({
+ className: 'update-history-log',
+ textContent: lines.join('\n'),
+ }),
+ buttons: [t('confirmOK')],
+ onshow: () => ($('#message-box-contents').scrollTop = 1e9),
+ });
+ });
+}
+
+
+function handleUpdateInstalled(entry) {
+ entry.classList.add('update-done');
+ entry.classList.remove('can-update', 'updatable');
+ $('.update-note', entry).textContent = t('updateCompleted');
+ renderUpdatesOnlyFilter();
+}