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(); +}