diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 05:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 05:47:55 +0000 |
commit | 31d6ff6f931696850c348007241195ab3b2eddc7 (patch) | |
tree | 615cb1c57ce9f6611bad93326b9105098f379609 /src/js/dyna-rules.js | |
parent | Initial commit. (diff) | |
download | ublock-origin-31d6ff6f931696850c348007241195ab3b2eddc7.tar.xz ublock-origin-31d6ff6f931696850c348007241195ab3b2eddc7.zip |
Adding upstream version 1.55.0+dfsg.upstream/1.55.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/js/dyna-rules.js')
-rw-r--r-- | src/js/dyna-rules.js | 678 |
1 files changed, 678 insertions, 0 deletions
diff --git a/src/js/dyna-rules.js b/src/js/dyna-rules.js new file mode 100644 index 0000000..ea79742 --- /dev/null +++ b/src/js/dyna-rules.js @@ -0,0 +1,678 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2014-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uMatrix +*/ + +/* global CodeMirror, diff_match_patch, uBlockDashboard */ + +'use strict'; + +import publicSuffixList from '../lib/publicsuffixlist/publicsuffixlist.js'; + +import { hostnameFromURI } from './uri-utils.js'; +import { i18n$ } from './i18n.js'; +import { dom, qs$, qsa$ } from './dom.js'; + +import './codemirror/ubo-dynamic-filtering.js'; + +/******************************************************************************/ + +const hostnameToDomainMap = new Map(); + +const mergeView = new CodeMirror.MergeView( + qs$('.codeMirrorMergeContainer'), + { + allowEditingOriginals: true, + connect: 'align', + inputStyle: 'contenteditable', + lineNumbers: true, + lineWrapping: false, + origLeft: '', + revertButtons: true, + value: '', + } +); +mergeView.editor().setOption('styleActiveLine', true); +mergeView.editor().setOption('lineNumbers', false); +mergeView.leftOriginal().setOption('readOnly', 'nocursor'); + +uBlockDashboard.patchCodeMirrorEditor(mergeView.editor()); + +const thePanes = { + orig: { + doc: mergeView.leftOriginal(), + original: [], + modified: [], + }, + edit: { + doc: mergeView.editor(), + original: [], + modified: [], + }, +}; + +let cleanEditToken = 0; +let cleanEditText = ''; +let isCollapsed = false; + +/******************************************************************************/ + +// The following code is to take care of properly internationalizing +// the tooltips of the arrows used by the CodeMirror merge view. These +// are hard-coded by CodeMirror ("Push to left", "Push to right"). An +// observer is necessary because there is no hook for uBO to overwrite +// reliably the default title attribute assigned by CodeMirror. + +{ + const i18nCommitStr = i18n$('rulesCommit'); + const i18nRevertStr = i18n$('rulesRevert'); + const commitArrowSelector = '.CodeMirror-merge-copybuttons-left .CodeMirror-merge-copy-reverse:not([title="' + i18nCommitStr + '"])'; + const revertArrowSelector = '.CodeMirror-merge-copybuttons-left .CodeMirror-merge-copy:not([title="' + i18nRevertStr + '"])'; + + dom.attr('.CodeMirror-merge-scrolllock', 'title', i18n$('genericMergeViewScrollLock')); + + const translate = function() { + let elems = qsa$(commitArrowSelector); + for ( const elem of elems ) { + dom.attr(elem, 'title', i18nCommitStr); + } + elems = qsa$(revertArrowSelector); + for ( const elem of elems ) { + dom.attr(elem, 'title', i18nRevertStr); + } + }; + + const mergeGapObserver = new MutationObserver(translate); + + mergeGapObserver.observe( + qs$('.CodeMirror-merge-copybuttons-left'), + { attributes: true, attributeFilter: [ 'title' ], subtree: true } + ); + +} + +/******************************************************************************/ + +const getDiffer = (( ) => { + let differ; + return ( ) => { + if ( differ === undefined ) { differ = new diff_match_patch(); } + return differ; + }; +})(); + +/******************************************************************************/ + +// Borrowed from... +// https://github.com/codemirror/CodeMirror/blob/3e1bb5fff682f8f6cbfaef0e56c61d62403d4798/addon/search/search.js#L22 +// ... and modified as needed. + +const updateOverlay = (( ) => { + let reFilter; + const mode = { + token: function(stream) { + if ( reFilter !== undefined ) { + reFilter.lastIndex = stream.pos; + let match = reFilter.exec(stream.string); + if ( match !== null ) { + if ( match.index === stream.pos ) { + stream.pos += match[0].length || 1; + return 'searching'; + } + stream.pos = match.index; + return; + } + } + stream.skipToEnd(); + } + }; + return function(filter) { + reFilter = typeof filter === 'string' && filter !== '' ? + new RegExp(filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') : + undefined; + return mode; + }; +})(); + +/******************************************************************************/ + +// Incrementally update text in a CodeMirror editor for best user experience: +// - Scroll position preserved +// - Minimum amount of text updated + +const rulesToDoc = function(clearHistory) { + const orig = thePanes.orig.doc; + const edit = thePanes.edit.doc; + orig.startOperation(); + edit.startOperation(); + + for ( const key in thePanes ) { + if ( thePanes.hasOwnProperty(key) === false ) { continue; } + const doc = thePanes[key].doc; + const rules = filterRules(key); + if ( + clearHistory || + doc.lineCount() === 1 && doc.getValue() === '' || + rules.length === 0 + ) { + doc.setValue(rules.length !== 0 ? rules.join('\n') + '\n' : ''); + continue; + } + // https://github.com/uBlockOrigin/uBlock-issues/issues/593 + // Ensure the text content always ends with an empty line to avoid + // spurious diff entries. + // https://github.com/uBlockOrigin/uBlock-issues/issues/657 + // Diff against unmodified beforeText so that the last newline can + // be reported in the diff and thus appended if needed. + let beforeText = doc.getValue(); + let afterText = rules.join('\n').trim(); + if ( afterText !== '' ) { afterText += '\n'; } + const diffs = getDiffer().diff_main(beforeText, afterText); + let i = diffs.length; + let iedit = beforeText.length; + while ( i-- ) { + const diff = diffs[i]; + if ( diff[0] === 0 ) { + iedit -= diff[1].length; + continue; + } + const end = doc.posFromIndex(iedit); + if ( diff[0] === 1 ) { + doc.replaceRange(diff[1], end, end); + continue; + } + /* diff[0] === -1 */ + iedit -= diff[1].length; + const beg = doc.posFromIndex(iedit); + doc.replaceRange('', beg, end); + } + } + + // Mark ellipses as read-only + const marks = edit.getAllMarks(); + for ( const mark of marks ) { + if ( mark.uboEllipsis !== true ) { continue; } + mark.clear(); + } + if ( isCollapsed ) { + for ( let iline = 0, n = edit.lineCount(); iline < n; iline++ ) { + if ( edit.getLine(iline) !== '...' ) { continue; } + const mark = edit.markText( + { line: iline, ch: 0 }, + { line: iline + 1, ch: 0 }, + { atomic: true, readOnly: true } + ); + mark.uboEllipsis = true; + } + } + + orig.endOperation(); + edit.endOperation(); + cleanEditText = mergeView.editor().getValue().trim(); + cleanEditToken = mergeView.editor().changeGeneration(); + + if ( clearHistory !== true ) { return; } + + mergeView.editor().clearHistory(); + const chunks = mergeView.leftChunks(); + if ( chunks.length === 0 ) { return; } + const ldoc = thePanes.orig.doc; + const { clientHeight } = ldoc.getScrollInfo(); + const line = Math.min(chunks[0].editFrom, chunks[0].origFrom); + ldoc.setCursor(line, 0); + ldoc.scrollIntoView( + { line, ch: 0 }, + (clientHeight - ldoc.defaultTextHeight()) / 2 + ); +}; + +/******************************************************************************/ + +const filterRules = function(key) { + const filter = qs$('#ruleFilter input').value; + const rules = thePanes[key].modified; + if ( filter === '' ) { return rules; } + const out = []; + for ( const rule of rules ) { + if ( rule.indexOf(filter) === -1 ) { continue; } + out.push(rule); + } + return out; +}; + +/******************************************************************************/ + +const applyDiff = async function(permanent, toAdd, toRemove) { + const details = await vAPI.messaging.send('dashboard', { + what: 'modifyRuleset', + permanent: permanent, + toAdd: toAdd, + toRemove: toRemove, + }); + thePanes.orig.original = details.permanentRules; + thePanes.edit.original = details.sessionRules; + onPresentationChanged(); +}; + +/******************************************************************************/ + +// CodeMirror quirk: sometimes fromStart.ch and/or toStart.ch is undefined. +// When this happens, use 0. + +mergeView.options.revertChunk = function( + mv, + from, fromStart, fromEnd, + to, toStart, toEnd +) { + // https://github.com/gorhill/uBlock/issues/3611 + if ( dom.attr(dom.body, 'dir') === 'rtl' ) { + let tmp = from; from = to; to = tmp; + tmp = fromStart; fromStart = toStart; toStart = tmp; + tmp = fromEnd; fromEnd = toEnd; toEnd = tmp; + } + if ( typeof fromStart.ch !== 'number' ) { fromStart.ch = 0; } + if ( fromEnd.ch !== 0 ) { fromEnd.line += 1; } + const toAdd = from.getRange( + { line: fromStart.line, ch: 0 }, + { line: fromEnd.line, ch: 0 } + ); + if ( typeof toStart.ch !== 'number' ) { toStart.ch = 0; } + if ( toEnd.ch !== 0 ) { toEnd.line += 1; } + const toRemove = to.getRange( + { line: toStart.line, ch: 0 }, + { line: toEnd.line, ch: 0 } + ); + applyDiff(from === mv.editor(), toAdd, toRemove); +}; + +/******************************************************************************/ + +function handleImportFilePicker() { + const fileReaderOnLoadHandler = function() { + if ( typeof this.result !== 'string' || this.result === '' ) { return; } + // https://github.com/chrisaljoudi/uBlock/issues/757 + // Support RequestPolicy rule syntax + let result = this.result; + let matches = /\[origins-to-destinations\]([^\[]+)/.exec(result); + if ( matches && matches.length === 2 ) { + result = matches[1].trim() + .replace(/\|/g, ' ') + .replace(/\n/g, ' * noop\n'); + } + applyDiff(false, result, ''); + }; + const file = this.files[0]; + if ( file === undefined || file.name === '' ) { return; } + if ( file.type.indexOf('text') !== 0 ) { return; } + const fr = new FileReader(); + fr.onload = fileReaderOnLoadHandler; + fr.readAsText(file); +} + +/******************************************************************************/ + +const startImportFilePicker = function() { + const input = qs$('#importFilePicker'); + // Reset to empty string, this will ensure an change event is properly + // triggered if the user pick a file, even if it is the same as the last + // one picked. + input.value = ''; + input.click(); +}; + +/******************************************************************************/ + +function exportUserRulesToFile() { + const filename = i18n$('rulesDefaultFileName') + .replace('{{datetime}}', uBlockDashboard.dateNowToSensibleString()) + .replace(/ +/g, '_'); + vAPI.download({ + url: 'data:text/plain,' + encodeURIComponent( + mergeView.leftOriginal().getValue().trim() + '\n' + ), + filename: filename, + saveAs: true + }); +} + +/******************************************************************************/ + +const onFilterChanged = (( ) => { + let timer; + let overlay = null; + let last = ''; + + const process = function() { + timer = undefined; + if ( mergeView.editor().isClean(cleanEditToken) === false ) { return; } + const filter = qs$('#ruleFilter input').value; + if ( filter === last ) { return; } + last = filter; + if ( overlay !== null ) { + mergeView.leftOriginal().removeOverlay(overlay); + mergeView.editor().removeOverlay(overlay); + overlay = null; + } + if ( filter !== '' ) { + overlay = updateOverlay(filter); + mergeView.leftOriginal().addOverlay(overlay); + mergeView.editor().addOverlay(overlay); + } + rulesToDoc(true); + }; + + return function() { + if ( timer !== undefined ) { self.cancelIdleCallback(timer); } + timer = self.requestIdleCallback(process, { timeout: 773 }); + }; +})(); + +/******************************************************************************/ + +const onPresentationChanged = (( ) => { + let sortType = 1; + + const reSwRule = /^([^/]+): ([^/ ]+) ([^ ]+)/; + const reRule = /^([^ ]+) ([^/ ]+) ([^ ]+ [^ ]+)/; + const reUrlRule = /^([^ ]+) ([^ ]+) ([^ ]+ [^ ]+)/; + + const sortNormalizeHn = function(hn) { + let domain = hostnameToDomainMap.get(hn); + if ( domain === undefined ) { + domain = /(\d|\])$/.test(hn) + ? hn + : publicSuffixList.getDomain(hn); + hostnameToDomainMap.set(hn, domain); + } + let normalized = domain || hn; + if ( hn.length !== domain.length ) { + const subdomains = hn.slice(0, hn.length - domain.length - 1); + normalized += '.' + ( + subdomains.includes('.') + ? subdomains.split('.').reverse().join('.') + : subdomains + ); + } + return normalized; + }; + + const slotFromRule = rule => { + let type, srcHn, desHn, extra; + let match = reSwRule.exec(rule); + if ( match !== null ) { + type = ' ' + match[1]; + srcHn = sortNormalizeHn(match[2]); + desHn = srcHn; + extra = match[3]; + } else if ( (match = reRule.exec(rule)) !== null ) { + type = '\x10FFFE'; + srcHn = sortNormalizeHn(match[1]); + desHn = sortNormalizeHn(match[2]); + extra = match[3]; + } else if ( (match = reUrlRule.exec(rule)) !== null ) { + type = '\x10FFFF'; + srcHn = sortNormalizeHn(match[1]); + desHn = sortNormalizeHn(hostnameFromURI(match[2])); + extra = match[3]; + } + if ( sortType === 0 ) { + return { rule, token: `${type} ${srcHn} ${desHn} ${extra}` }; + } + if ( sortType === 1 ) { + return { rule, token: `${srcHn} ${type} ${desHn} ${extra}` }; + } + return { rule, token: `${desHn} ${type} ${srcHn} ${extra}` }; + }; + + const sort = rules => { + const slots = []; + for ( let i = 0; i < rules.length; i++ ) { + slots.push(slotFromRule(rules[i], 1)); + } + slots.sort((a, b) => a.token.localeCompare(b.token)); + for ( let i = 0; i < rules.length; i++ ) { + rules[i] = slots[i].rule; + } + }; + + const collapse = ( ) => { + if ( isCollapsed !== true ) { return; } + const diffs = getDiffer().diff_main( + thePanes.orig.modified.join('\n'), + thePanes.edit.modified.join('\n') + ); + const ll = []; let il = 0, lellipsis = false; + const rr = []; let ir = 0, rellipsis = false; + for ( let i = 0; i < diffs.length; i++ ) { + const diff = diffs[i]; + if ( diff[0] === 0 ) { + lellipsis = rellipsis = true; + il += 1; ir += 1; + continue; + } + if ( diff[0] < 0 ) { + if ( lellipsis ) { + ll.push('...'); + if ( rellipsis ) { rr.push('...'); } + lellipsis = rellipsis = false; + } + ll.push(diff[1].trim()); + il += 1; + continue; + } + /* diff[0] > 0 */ + if ( rellipsis ) { + rr.push('...'); + if ( lellipsis ) { ll.push('...'); } + lellipsis = rellipsis = false; + } + rr.push(diff[1].trim()); + ir += 1; + } + if ( lellipsis ) { ll.push('...'); } + if ( rellipsis ) { rr.push('...'); } + thePanes.orig.modified = ll; + thePanes.edit.modified = rr; + }; + + return function(clearHistory) { + const origPane = thePanes.orig; + const editPane = thePanes.edit; + origPane.modified = origPane.original.slice(); + editPane.modified = editPane.original.slice(); + const select = qs$('#ruleFilter select'); + sortType = parseInt(select.value, 10); + if ( isNaN(sortType) ) { sortType = 1; } + { + const mode = origPane.doc.getMode(); + mode.sortType = sortType; + mode.setHostnameToDomainMap(hostnameToDomainMap); + mode.setPSL(publicSuffixList); + } + { + const mode = editPane.doc.getMode(); + mode.sortType = sortType; + mode.setHostnameToDomainMap(hostnameToDomainMap); + mode.setPSL(publicSuffixList); + } + sort(origPane.modified); + sort(editPane.modified); + collapse(); + rulesToDoc(clearHistory); + onTextChanged(clearHistory); + }; +})(); + +/******************************************************************************/ + +const onTextChanged = (( ) => { + let timer; + + const process = details => { + timer = undefined; + const diff = qs$('#diff'); + let isClean = mergeView.editor().isClean(cleanEditToken); + if ( + details === undefined && + isClean === false && + mergeView.editor().getValue().trim() === cleanEditText + ) { + cleanEditToken = mergeView.editor().changeGeneration(); + isClean = true; + } + const isDirty = mergeView.leftChunks().length !== 0; + dom.cl.toggle(dom.body, 'editing', isClean === false); + dom.cl.toggle(diff, 'dirty', isDirty); + dom.cl.toggle('#editSaveButton', 'disabled', isClean); + dom.cl.toggle('#exportButton,#importButton', 'disabled', isClean === false); + dom.cl.toggle('#revertButton,#commitButton', 'disabled', isClean === false || isDirty === false); + const input = qs$('#ruleFilter input'); + if ( isClean ) { + dom.attr(input, 'disabled', null); + CodeMirror.commands.save = undefined; + } else { + dom.attr(input, 'disabled', ''); + CodeMirror.commands.save = editSaveHandler; + } + }; + + return function(now) { + if ( timer !== undefined ) { self.cancelIdleCallback(timer); } + timer = now ? process() : self.requestIdleCallback(process, { timeout: 57 }); + }; +})(); + +/******************************************************************************/ + +const revertAllHandler = function() { + const toAdd = [], toRemove = []; + const left = mergeView.leftOriginal(); + const edit = mergeView.editor(); + for ( const chunk of mergeView.leftChunks() ) { + const addedLines = left.getRange( + { line: chunk.origFrom, ch: 0 }, + { line: chunk.origTo, ch: 0 } + ); + const removedLines = edit.getRange( + { line: chunk.editFrom, ch: 0 }, + { line: chunk.editTo, ch: 0 } + ); + toAdd.push(addedLines.trim()); + toRemove.push(removedLines.trim()); + } + applyDiff(false, toAdd.join('\n'), toRemove.join('\n')); +}; + +/******************************************************************************/ + +const commitAllHandler = function() { + const toAdd = [], toRemove = []; + const left = mergeView.leftOriginal(); + const edit = mergeView.editor(); + for ( const chunk of mergeView.leftChunks() ) { + const addedLines = edit.getRange( + { line: chunk.editFrom, ch: 0 }, + { line: chunk.editTo, ch: 0 } + ); + const removedLines = left.getRange( + { line: chunk.origFrom, ch: 0 }, + { line: chunk.origTo, ch: 0 } + ); + toAdd.push(addedLines.trim()); + toRemove.push(removedLines.trim()); + } + applyDiff(true, toAdd.join('\n'), toRemove.join('\n')); +}; + +/******************************************************************************/ + +const editSaveHandler = function() { + const editor = mergeView.editor(); + const editText = editor.getValue().trim(); + if ( editText === cleanEditText ) { + onTextChanged(true); + return; + } + const toAdd = [], toRemove = []; + const diffs = getDiffer().diff_main(cleanEditText, editText); + for ( const diff of diffs ) { + if ( diff[0] === 1 ) { + toAdd.push(diff[1]); + } else if ( diff[0] === -1 ) { + toRemove.push(diff[1]); + } + } + applyDiff(false, toAdd.join(''), toRemove.join('')); +}; + +/******************************************************************************/ + +self.cloud.onPush = function() { + return thePanes.orig.original.join('\n'); +}; + +self.cloud.onPull = function(data, append) { + if ( typeof data !== 'string' ) { return; } + applyDiff( + false, + data, + append ? '' : mergeView.editor().getValue().trim() + ); +}; + +/******************************************************************************/ + +self.hasUnsavedData = function() { + return mergeView.editor().isClean(cleanEditToken) === false; +}; + +/******************************************************************************/ + +vAPI.messaging.send('dashboard', { + what: 'getRules', +}).then(details => { + thePanes.orig.original = details.permanentRules; + thePanes.edit.original = details.sessionRules; + publicSuffixList.fromSelfie(details.pslSelfie); + onPresentationChanged(true); +}); + +// Handle user interaction +dom.on('#importButton', 'click', startImportFilePicker); +dom.on('#importFilePicker', 'change', handleImportFilePicker); +dom.on('#exportButton', 'click', exportUserRulesToFile); +dom.on('#revertButton', 'click', revertAllHandler); +dom.on('#commitButton', 'click', commitAllHandler); +dom.on('#editSaveButton', 'click', editSaveHandler); +dom.on('#ruleFilter input', 'input', onFilterChanged); +dom.on('#ruleFilter select', 'input', ( ) => { + onPresentationChanged(true); +}); +dom.on('#ruleFilter #diffCollapse', 'click', ev => { + isCollapsed = dom.cl.toggle(ev.target, 'active'); + onPresentationChanged(true); +}); + +// https://groups.google.com/forum/#!topic/codemirror/UQkTrt078Vs +mergeView.editor().on('updateDiff', ( ) => { + onTextChanged(); +}); + +/******************************************************************************/ + |