mirror of
https://github.com/gorhill/uBlock.git
synced 2026-03-11 09:04:36 +00:00
Ultimately and eventually, the built-in parser will be used to convert filters to DNR rules at extension building time.
409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
/*******************************************************************************
|
|
|
|
uBlock Origin Lite - a comprehensive, MV3-compliant 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/uBlock
|
|
*/
|
|
|
|
import {
|
|
browser,
|
|
localRead,
|
|
localRemove,
|
|
localWrite,
|
|
sendMessage,
|
|
} from './ext.js';
|
|
import { dom, qs$ } from './dom.js';
|
|
import { i18n, i18n$ } from './i18n.js';
|
|
import { DNREditor } from './dnr-editor.js';
|
|
import { parseFilters } from './ubo-parser.js';
|
|
import { textFromRules } from './dnr-parser.js';
|
|
|
|
/******************************************************************************/
|
|
|
|
export class ReadWriteDNREditor extends DNREditor {
|
|
constructor(editor) {
|
|
super();
|
|
this.feedbackPanel = self.cm6.createViewPanel();
|
|
editor.panels.push(this.feedbackPanel);
|
|
}
|
|
|
|
async getText() {
|
|
return localRead('userDnrRules');
|
|
}
|
|
|
|
on(editor) {
|
|
localRead('userDnrRuleCount').then(userDnrRuleCount => {
|
|
this.updateSummaryPanel(editor, { userDnrRuleCount })
|
|
});
|
|
browser.storage.onChanged.addListener((changes, area) => {
|
|
if ( area !== 'local' ) { return; }
|
|
const { userDnrRuleCount } = changes;
|
|
if ( userDnrRuleCount instanceof Object === false ) { return; }
|
|
const { newValue } = changes.userDnrRuleCount;
|
|
this.updateSummaryPanel(editor, { userDnrRuleCount: newValue });
|
|
});
|
|
}
|
|
|
|
off(editor) {
|
|
this.updateSummaryPanel(editor, null);
|
|
this.updateFeedbackPanel(editor, null);
|
|
}
|
|
|
|
rulesFromJSON(json) {
|
|
let content = json.trim();
|
|
if ( /^[[{]/.test(content) === false ) {
|
|
const match = /^[^[{]+/.exec(content);
|
|
if ( match === null ) { return; }
|
|
content = content.slice(match[0].length);
|
|
}
|
|
const firstChar = content.charAt(0);
|
|
const expectedLastChar = firstChar === '[' ? ']' : '}';
|
|
if ( content.at(-1) !== expectedLastChar ) {
|
|
const re = new RegExp(`\\${expectedLastChar}[^\\${expectedLastChar}]+$`);
|
|
const match = re.exec(content);
|
|
if ( match === null ) { return; }
|
|
content = content.slice(0, match.index+1);
|
|
}
|
|
if ( content.startsWith('{') && content.endsWith('}') ) {
|
|
content = `[${content}]`;
|
|
}
|
|
try {
|
|
const rules = JSON.parse(content);
|
|
if ( Array.isArray(rules) ) { return rules; }
|
|
}
|
|
catch {
|
|
}
|
|
}
|
|
|
|
getAutocompleteCandidates(editor, from) {
|
|
const { scope } = editor.getScopeAt(from);
|
|
switch ( scope ) {
|
|
case '':
|
|
return {
|
|
before: /^$/,
|
|
candidates: [
|
|
{ token: 'action:', after: '\n' },
|
|
{ token: 'condition:', after: '\n ' },
|
|
{ token: 'priority:', after: ' ' },
|
|
{ token: '---', after: '\n' },
|
|
]
|
|
};
|
|
case 'action:':
|
|
return {
|
|
before: /^ {2}$/,
|
|
candidates: [
|
|
{ token: 'type:', after: ' ' },
|
|
{ token: 'redirect:', after: '\n ' },
|
|
{ token: 'requestHeaders:', after: '\n - header: ' },
|
|
{ token: 'responseHeaders:', after: '\n - header: ' },
|
|
],
|
|
};
|
|
case 'action:type:':
|
|
return {
|
|
before: /: $/,
|
|
candidates: [
|
|
{ token: 'block', after: '\n ' },
|
|
{ token: 'redirect', after: '\n ' },
|
|
{ token: 'allow', after: '\n ' },
|
|
{ token: 'modifyHeaders', after: '\n ' },
|
|
{ token: 'upgradeScheme', after: '\n ' },
|
|
{ token: 'allowAllRequest', after: '\n ' },
|
|
],
|
|
};
|
|
case 'action:redirect:':
|
|
return {
|
|
before: /^ {4}$/,
|
|
candidates: [
|
|
{ token: 'extensionPath:', after: ' ' },
|
|
{ token: 'regexSubstitution:', after: ' ' },
|
|
{ token: 'transform:', after: '\n ' },
|
|
{ token: 'url:', after: ' ' },
|
|
],
|
|
};
|
|
case 'action:redirect:transform:':
|
|
return {
|
|
before: /^ {6}$/,
|
|
candidates: [
|
|
{ token: 'fragment:', after: ' ' },
|
|
{ token: 'host:', after: ' ' },
|
|
{ token: 'path:', after: ' ' },
|
|
{ token: 'port:', after: ' ' },
|
|
{ token: 'query:', after: ' ' },
|
|
{ token: 'scheme:', after: ' ' },
|
|
{ token: 'queryTransform:', after: '\n ' },
|
|
],
|
|
};
|
|
case 'action:redirect:transform:queryTransform:':
|
|
return {
|
|
before: /^ {8}$/,
|
|
candidates: [
|
|
{ token: 'addOrReplaceParams:', after: '\n - ' },
|
|
{ token: 'removeParams:', after: '\n - ' },
|
|
],
|
|
};
|
|
case 'action:responseHeaders:':
|
|
case 'action:requestHeaders:':
|
|
return {
|
|
before: /^ {4}- $/,
|
|
candidates: [
|
|
{ token: 'header:', after: ' ' },
|
|
],
|
|
};
|
|
case 'action:responseHeaders:header:':
|
|
case 'action:requestHeaders:header:':
|
|
return {
|
|
before: /^ {6}$/,
|
|
candidates: [
|
|
{ token: 'operation:', after: ' ' },
|
|
{ token: 'value:', after: ' ' },
|
|
],
|
|
};
|
|
case 'action:responseHeaders:header:operation:':
|
|
case 'action:requestHeaders:header:operation:':
|
|
return {
|
|
before: /: $/,
|
|
candidates: [
|
|
{ token: 'append', after: '\n value: ' },
|
|
{ token: 'set', after: '\n value: ' },
|
|
{ token: 'remove', after: '\n ' },
|
|
],
|
|
};
|
|
case 'condition:':
|
|
return {
|
|
before: /^ {2}$/,
|
|
candidates: [
|
|
{ token: 'domainType:', after: ' ' },
|
|
{ token: 'isUrlFilterCaseSensitive:', after: ' ' },
|
|
{ token: 'regexFilter:', after: ' ' },
|
|
{ token: 'urlFilter:', after: ' ' },
|
|
{ token: 'initiatorDomains:', after: '\n - ' },
|
|
{ token: 'excludedInitiatorDomains:', after: '\n - ' },
|
|
{ token: 'requestDomains:', after: '\n - ' },
|
|
{ token: 'excludedRequestDomains:', after: '\n - ' },
|
|
{ token: 'resourceTypes:', after: '\n - ' },
|
|
{ token: 'excludedResourceTypes:', after: '\n - ' },
|
|
{ token: 'requestMethods:', after: '\n - ' },
|
|
{ token: 'excludedRequestMethods:', after: '\n - ' },
|
|
{ token: 'responseHeaders:', after: '\n - ' },
|
|
{ token: 'excludedResponseHeaders:', after: '\n - ' },
|
|
],
|
|
};
|
|
case 'condition:domainType:':
|
|
return {
|
|
before: /: $/,
|
|
candidates: [
|
|
{ token: 'firstParty', after: '\n ' },
|
|
{ token: 'thirdParty', after: '\n ' },
|
|
],
|
|
};
|
|
case 'condition:isUrlFilterCaseSensitive:':
|
|
return {
|
|
before: /: $/,
|
|
candidates: [
|
|
{ token: 'true', after: '\n ' },
|
|
{ token: 'false', after: '\n ' },
|
|
],
|
|
};
|
|
case 'condition:requestMethods:':
|
|
case 'condition:excludedRequestMethods:':
|
|
return {
|
|
before: /^ {4}- $/,
|
|
candidates: [
|
|
{ token: 'connect', after: '\n - ' },
|
|
{ token: 'delete', after: '\n - ' },
|
|
{ token: 'get', after: '\n - ' },
|
|
{ token: 'head', after: '\n - ' },
|
|
{ token: 'options', after: '\n - ' },
|
|
{ token: 'patch', after: '\n - ' },
|
|
{ token: 'post', after: '\n - ' },
|
|
{ token: 'put', after: '\n - ' },
|
|
{ token: 'other', after: '\n ' },
|
|
],
|
|
};
|
|
case 'condition:resourceTypes:':
|
|
case 'condition:excludedResourceTypes:':
|
|
return {
|
|
before: /^ {4}- $/,
|
|
candidates: [
|
|
{ token: 'main_frame', after: '\n - ' },
|
|
{ token: 'sub_frame', after: '\n - ' },
|
|
{ token: 'stylesheet', after: '\n - ' },
|
|
{ token: 'script', after: '\n - ' },
|
|
{ token: 'image', after: '\n - ' },
|
|
{ token: 'font', after: '\n - ' },
|
|
{ token: 'object', after: '\n - ' },
|
|
{ token: 'xmlhttprequest', after: '\n - ' },
|
|
{ token: 'ping', after: '\n - ' },
|
|
{ token: 'csp_report', after: '\n - ' },
|
|
{ token: 'media', after: '\n - ' },
|
|
{ token: 'websocket', after: '\n - ' },
|
|
{ token: 'webtransport', after: '\n - ' },
|
|
{ token: 'webbundle', after: '\n - ' },
|
|
{ token: 'other', after: '\n ' },
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
autoComplete(editor, context) {
|
|
const match = context.matchBefore(/[\w-]*/);
|
|
if ( match === undefined ) { return null; }
|
|
const result = this.getAutocompleteCandidates(editor, match.from);
|
|
if ( result === undefined ) { return null; }
|
|
if ( result.before !== undefined ) {
|
|
const { doc } = context.state;
|
|
const line = doc.lineAt(context.pos);
|
|
const before = doc.sliceString(line.from, match.from);
|
|
if ( result.before.test(before) === false ) { return null; }
|
|
}
|
|
const filtered = result.candidates.filter(e =>
|
|
e.token !== match.text || e.after !== '\n'
|
|
);
|
|
return {
|
|
from: match.from,
|
|
options: filtered.map(e => ({ label: e.token, apply: `${e.token}${e.after}` })),
|
|
validFor: /\w*/,
|
|
};
|
|
}
|
|
|
|
async saveEditorText(editor) {
|
|
const text = editor.getEditorText().trim();
|
|
await (text.length !== 0
|
|
? localWrite('userDnrRules', text)
|
|
: localRemove('userDnrRules')
|
|
);
|
|
const response = await sendMessage({ what: 'updateUserDnrRules' })
|
|
if ( response instanceof Object ) {
|
|
this.updateFeedbackPanel(editor, response);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
updateSummaryPanel(editor, details) {
|
|
if ( details instanceof Object === false ) {
|
|
return editor.updateSummaryPanel(null);
|
|
}
|
|
const template = document.querySelector('template.summary-panel');
|
|
const fragment = template.content.cloneNode(true);
|
|
const root = fragment.querySelector('.summary-panel');
|
|
i18n.render(root);
|
|
const info = root.querySelector('.info');
|
|
info.textContent = i18n$('dnrRulesCountInfo')
|
|
.replace('{count}', (details.userDnrRuleCount || 0).toLocaleString())
|
|
editor.updateSummaryPanel(root);
|
|
}
|
|
|
|
updateFeedbackPanel(editor, details) {
|
|
if ( details instanceof Object === false ) {
|
|
return this.feedbackPanel.render(editor.view, null);
|
|
}
|
|
const errors = [];
|
|
if ( Array.isArray(details.errors) ) {
|
|
details.errors.forEach(e => errors.push(e));
|
|
}
|
|
const text = errors.join('\n');
|
|
const config = (( ) => {
|
|
if ( text === '' ) { return null; }
|
|
const template = document.querySelector('template.feedback-panel');
|
|
const fragment = template.content.cloneNode(true);
|
|
const root = fragment.querySelector('.feedback-panel');
|
|
const info = root.querySelector('.info');
|
|
info.textContent = text;
|
|
const closeFn = this.updateFeedbackPanel.bind(this, editor, null);
|
|
return {
|
|
dom: root,
|
|
mount() {
|
|
dom.on(qs$('.feedback-panel .close'), 'click', closeFn);
|
|
}
|
|
};
|
|
})();
|
|
this.feedbackPanel.render(editor.view, config);
|
|
}
|
|
|
|
importFromFile(editor, json) {
|
|
const rules = this.rulesFromJSON(json);
|
|
if ( rules === undefined ) { return; }
|
|
const text = textFromRules(rules);
|
|
if ( text === undefined ) { return; }
|
|
const { doc } = editor.view.state;
|
|
const lastChars = doc.toString().trimEnd().slice(-4);
|
|
const lastLine = doc.line(doc.lines);
|
|
let from = lastLine.to;
|
|
let prepend = '';
|
|
if ( lastLine.text !== '' ) {
|
|
prepend = '\n';
|
|
} else {
|
|
from = lastLine.from;
|
|
}
|
|
if ( /(?:^|\n)---$/.test(lastChars) === false ) {
|
|
prepend = `${prepend}---\n`;
|
|
}
|
|
editor.view.dispatch({ changes: { from, insert: `${prepend}${text}` } });
|
|
self.cm6.foldAll(editor.view);
|
|
editor.view.focus();
|
|
}
|
|
|
|
exportToFile(text) {
|
|
return super.exportToFile(text, 'my-ubol-dnr-rules.json');
|
|
}
|
|
|
|
importFromPaste(editor, transaction) {
|
|
const { from, to } = editor.rangeFromTransaction(transaction);
|
|
if ( from === undefined || to === undefined ) { return; }
|
|
// Paste position must match start of a line
|
|
const { newDoc } = transaction;
|
|
const lineFrom = newDoc.lineAt(from);
|
|
if ( lineFrom.from !== from ) { return; }
|
|
// Paste position must match a rule boundary
|
|
let separatorBefore = false;
|
|
if ( lineFrom.number !== 1 ) {
|
|
const lineBefore = newDoc.line(lineFrom.number-1);
|
|
if ( /^---\s*$/.test(lineBefore.text) === false ) { return; }
|
|
separatorBefore = true;
|
|
}
|
|
const pastedText = newDoc.sliceString(from, to);
|
|
let linesToPrepend;
|
|
let rules = this.rulesFromJSON(pastedText);
|
|
if ( Boolean(rules?.length) === false ) {
|
|
rules = parseFilters(pastedText);
|
|
if ( Boolean(rules?.length) === false ) { return; }
|
|
const lines = pastedText.trim().split(/\n/);
|
|
linesToPrepend = lines.slice(0, 10).map(a => `# ${a}`);
|
|
if ( lines.length > linesToPrepend.length ) {
|
|
linesToPrepend.push('# ...');
|
|
}
|
|
}
|
|
let yamlText = textFromRules(rules);
|
|
if ( yamlText === undefined ) { return; }
|
|
if ( linesToPrepend ) {
|
|
yamlText = yamlText.replace('---\n', `---\n${linesToPrepend.join('\n')}\n`);
|
|
}
|
|
if ( separatorBefore && yamlText.startsWith('---\n') ) {
|
|
yamlText = yamlText.slice(4);
|
|
}
|
|
editor.view.dispatch({ changes: { from, to, insert: yamlText } });
|
|
self.cm6.foldAll(editor.view);
|
|
return true;
|
|
}
|
|
|
|
newlineAssistant = {
|
|
'action:': ' type: ',
|
|
'action:responseHeaders:header:': ' operation: ',
|
|
};
|
|
|
|
ioAccept = '.json,application/json';
|
|
};
|