diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json
index e0c2038f2..f248475c1 100644
--- a/platform/mv3/extension/_locales/en/messages.json
+++ b/platform/mv3/extension/_locales/en/messages.json
@@ -295,6 +295,30 @@
"message": "Exit element zapper mode",
"description": "Tooltip for the button used to exit zapper mode"
},
+ "developDropdownLabel": {
+ "message": "View:",
+ "description": "A label of a dropdown list"
+ },
+ "developOptionFilteringModeDetails": {
+ "message": "Filtering mode details",
+ "description": "An option in a dropdown list"
+ },
+ "developOptionCustomDnrRules": {
+ "message": "Custom DNR rules",
+ "description": "An option in a dropdown list"
+ },
+ "developOptionDnrRulesOf": {
+ "message": "DNR rules of …",
+ "description": "A section header in a dropdown list"
+ },
+ "developOptionDynamicRuleset": {
+ "message": "Dynamic ruleset",
+ "description": "An option in a dropdown list"
+ },
+ "developOptionSessionRuleset": {
+ "message": "Session ruleset",
+ "description": "An option in a dropdown list"
+ },
"saveButton": {
"message": "Save",
"description": "Text for buttons used to save changes"
@@ -311,8 +335,8 @@
"message": "Export…",
"description": "Text for buttons used to export content"
},
- "dnrRulesSummary": {
- "message": "Enter your own DNR rules below. Do not add content from untrusted sources.",
+ "dnrRulesWarning": {
+ "message": "Do not add content from untrusted sources",
"description": "Short description of the DNR rules editor pane"
},
"dnrRulesCountInfo": {
diff --git a/platform/mv3/extension/css/develop.css b/platform/mv3/extension/css/develop.css
index 1ecc3ffd3..665206d00 100644
--- a/platform/mv3/extension/css/develop.css
+++ b/platform/mv3/extension/css/develop.css
@@ -19,38 +19,35 @@ section[data-pane="develop"] > div > * {
margin-top: 1em;
}
-#cm-dnrRules {
+#cm-container {
flex-grow: 1;
font-size: var(--monospace-size);
overflow: hidden;
}
/* https://discuss.codemirror.net/t/how-to-set-max-height-of-the-editor/2882/2 */
-#cm-dnrRules .cm-editor {
+#cm-container .cm-editor {
background-color: var(--surface-0);
height: 100%;
}
-#cm-dnrRules .cm-editor .cm-line {
- border-bottom: 1px dotted transparent;
- border-top: 1px dotted transparent;
+#cm-container .cm-editor .cm-line:has(.ͼ5),
+#cm-container .cm-editor .cm-line:has(.ͼw) {
+ background-image: url('line-hor-dashed.png'), url('line-hor-dashed.png');
+ background-position: left 3px, left calc(100% - 3px);
+ background-repeat: repeat-x;
}
-#cm-dnrRules .cm-editor .cm-line:has(.ͼc) {
- border-bottom: 1px dotted var(--border-1);
- border-top: 1px dotted var(--border-1);
- }
-
-#cm-dnrRules .cm-editor .cm-line.badline:not(.cm-activeLine) {
+#cm-container .cm-editor .cm-line.badline:not(.cm-activeLine) {
background-color: color-mix(in srgb, var(--info3-ink) 15%, transparent 85%);
}
-#cm-dnrRules .cm-editor .cm-line .badmark {
+#cm-container .cm-editor .cm-line .badmark {
text-decoration: underline var(--cm-negative) wavy;
text-decoration-skip-ink: none;
}
-#cm-dnrRules .cm-editor .cm-panel.cm-search {
+#cm-container .cm-editor .cm-panel.cm-search {
display: flex;
flex-wrap: wrap;
font-family: sans-serif;
@@ -59,13 +56,13 @@ section[data-pane="develop"] > div > * {
padding: 0.5em 1.5em 0.5em 0.5em;
}
-#cm-dnrRules .cm-editor .cm-panel.cm-search > * {
+#cm-container .cm-editor .cm-panel.cm-search > * {
margin: 0;
}
-#cm-dnrRules .cm-editor .cm-panel.cm-search .cm-textfield,
-#cm-dnrRules .cm-editor .cm-panel.cm-search .cm-button,
-#cm-dnrRules .cm-editor .cm-panel.cm-search label {
+#cm-container .cm-editor .cm-panel.cm-search .cm-textfield,
+#cm-container .cm-editor .cm-panel.cm-search .cm-button,
+#cm-container .cm-editor .cm-panel.cm-search label {
background-image: inherit;
border: inherit;
flex-grow: 0;
@@ -73,40 +70,77 @@ section[data-pane="develop"] > div > * {
min-height: calc(var(--button-font-size) * 1.8);
}
-#cm-dnrRules .cm-editor .cm-panel.info-panel {
+#cm-container .cm-editor .cm-panel .warning {
+ color: var(--info3-ink);
+ }
+
+#cm-container .cm-editor .cm-panel.io-panel {
+ background-color: var(--surface-1);
+ box-sizing: border-box;
+ display: inline-flex;
+ gap: 0.25em;
+ padding: 0.25em;
+ padding-inline-start: 0;
+ width: 100%;
+ }
+#cm-container .cm-editor .cm-panel.io-panel button {
+ min-height: 30px;
+ }
+#cm-container .cm-editor .cm-panel.io-panel button#revert {
+ margin-inline-end: 1em;
+ }
+#cm-container .cm-editor .cm-panel.io-panel:not([data-io~="apply"]) button#apply {
+ display: none;
+ }
+#cm-container .cm-editor .cm-panel.io-panel:not([data-io~="revert"]) button#revert {
+ display: none;
+ }
+#cm-container .cm-editor .cm-panel.io-panel:not([data-io~="import"]) button#import {
+ display: none;
+ }
+#cm-container .cm-editor .cm-panel.io-panel:not([data-io~="export"]) button#export {
+ display: none;
+ }
+
+#cm-container .cm-editor .cm-panel.info-panel {
display: flex;
flex-wrap: nowrap;
font-size: var(--font-size);
padding: var(--default-gap-xxsmall) var(--default-gap-xsmall);
}
-#cm-dnrRules .cm-editor .cm-panel.info-panel .info {
+#cm-container .cm-editor .cm-panel.info-panel .info {
flex-grow: 1;
overflow: auto;
}
-#cm-dnrRules .cm-editor .cm-panel.info-panel .close {
+#cm-container .cm-editor .cm-panel.info-panel .close {
cursor: default;
flex-shrink: 0;
padding-inline-start: 1em;
}
-#cm-dnrRules .cm-editor .cm-panel.info-panel .close::after {
+#cm-container .cm-editor .cm-panel.info-panel .close::after {
content: '\2715';
}
-#cm-dnrRules .cm-editor .cm-panel.summary-panel {
+
+#cm-container .cm-editor .cm-panel.summary-panel {
background-color: color-mix(in srgb, var(--info1-ink) 15%, transparent 85%);
+ gap: 1em;
+ }
+#cm-container .cm-editor .cm-panel.summary-panel .info {
+ flex-shrink: 0;
}
-#cm-dnrRules .cm-editor .cm-panel.feedback-panel {
+#cm-container .cm-editor .cm-panel.feedback-panel {
background-color: color-mix(in srgb, var(--info3-ink) 15%, transparent 85%);
white-space: pre;
max-height: 10cqh;
}
-#cm-dnrRules .cm-editor .cm-gutterElement {
+#cm-container .cm-editor .cm-gutterElement {
cursor: default;
user-select: none;
}
-#cm-dnrRules .cm-editor .cm-tooltip .badmark-tooltip {
+#cm-container .cm-editor .cm-tooltip .badmark-tooltip {
background-color: color-mix(in srgb, var(--info3-ink) 15%, transparent 85%);
padding: var(--default-gap-xxsmall) var(--default-gap-xsmall);
}
\ No newline at end of file
diff --git a/platform/mv3/extension/css/line-hor-dashed.png b/platform/mv3/extension/css/line-hor-dashed.png
new file mode 100644
index 000000000..cb124a2b5
Binary files /dev/null and b/platform/mv3/extension/css/line-hor-dashed.png differ
diff --git a/platform/mv3/extension/dashboard.html b/platform/mv3/extension/dashboard.html
index 91306b845..ee64de6bf 100644
--- a/platform/mv3/extension/dashboard.html
+++ b/platform/mv3/extension/dashboard.html
@@ -89,12 +89,6 @@
-
-
@@ -108,14 +102,17 @@
-
-
-
-
-
+
-
-
+
@@ -165,16 +162,24 @@
+
+
+
+
+
+
+
+
@@ -183,6 +188,10 @@
+
+
+
+
diff --git a/platform/mv3/extension/js/admin.js b/platform/mv3/extension/js/admin.js
index 865f37dda..1df2c5079 100644
--- a/platform/mv3/extension/js/admin.js
+++ b/platform/mv3/extension/js/admin.js
@@ -21,7 +21,7 @@
import {
adminRead,
- localRead, localWrite,
+ localRead, localRemove, localWrite,
sessionRead, sessionWrite,
} from './ext.js';
@@ -33,7 +33,6 @@ import {
import {
getDefaultFilteringMode,
- getTrustedSites,
readFilteringModeDetails,
} from './mode-manager.js';
@@ -132,9 +131,8 @@ const adminSettings = {
}
if ( this.keys.has('noFiltering') ) {
ubolLog('admin setting "noFiltering" changed');
- await readFilteringModeDetails(true);
- const trustedSites = await getTrustedSites();
- broadcastMessage({ trustedSites: Array.from(trustedSites) });
+ const filteringModeDetails = await readFilteringModeDetails(true);
+ broadcastMessage({ filteringModeDetails });
}
if ( this.keys.has('showBlockedCount') ) {
ubolLog('admin setting "showBlockedCount" changed');
@@ -192,17 +190,18 @@ export async function getAdminRulesets() {
export async function adminReadEx(key) {
let cacheValue;
- const session = await sessionRead(`admin_${key}`);
+ const session = await sessionRead(`admin.${key}`);
if ( session ) {
cacheValue = session.data;
} else {
- const local = await localRead(`admin_${key}`);
+ const local = await localRead(`admin.${key}`);
if ( local ) {
cacheValue = local.data;
}
+ localRemove(`admin_${key}`); // TODO: remove eventually
}
adminRead(key).then(async value => {
- const adminKey = `admin_${key}`;
+ const adminKey = `admin.${key}`;
await Promise.all([
sessionWrite(adminKey, { data: value }),
localWrite(adminKey, { data: value }),
diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js
index f07adb301..ffeb0314f 100644
--- a/platform/mv3/extension/js/background.js
+++ b/platform/mv3/extension/js/background.js
@@ -24,10 +24,10 @@ import {
MODE_OPTIMAL,
getDefaultFilteringMode,
getFilteringMode,
- getTrustedSites,
+ getFilteringModeDetails,
setDefaultFilteringMode,
setFilteringMode,
- setTrustedSites,
+ setFilteringModeDetails,
syncWithBrowserPermissions,
} from './mode-manager.js';
@@ -53,6 +53,8 @@ import {
import {
enableRulesets,
excludeFromStrictBlock,
+ getEffectiveDynamicRules,
+ getEffectiveSessionRules,
getEffectiveUserRules,
getEnabledRulesetsDetails,
getRulesetDetails,
@@ -219,11 +221,10 @@ function onMessage(request, sender, callback) {
return true;
}
- case 'getOptionsPageData': {
+ case 'getOptionsPageData':
Promise.all([
hasBroadHostPermissions(),
getDefaultFilteringMode(),
- getTrustedSites(),
getRulesetDetails(),
dnr.getEnabledRulesets(),
getAdminRulesets(),
@@ -232,7 +233,6 @@ function onMessage(request, sender, callback) {
const [
hasOmnipotence,
defaultFilteringMode,
- trustedSites,
rulesetDetails,
enabledRulesets,
adminRulesets,
@@ -241,7 +241,6 @@ function onMessage(request, sender, callback) {
callback({
hasOmnipotence,
defaultFilteringMode,
- trustedSites: Array.from(trustedSites),
enabledRulesets,
adminRulesets,
maxNumberOfEnabledRulesets: dnr.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
@@ -258,7 +257,12 @@ function onMessage(request, sender, callback) {
process.firstRun = false;
});
return true;
- }
+
+ case 'getRulesetDetails':
+ getRulesetDetails().then(rulesetDetails => {
+ callback(Array.from(rulesetDetails.values()));
+ });
+ return true;
case 'setAutoReload':
rulesetConfig.autoReload = request.state && true || false;
@@ -353,7 +357,7 @@ function onMessage(request, sender, callback) {
return true;
}
- case 'setDefaultFilteringMode': {
+ case 'setDefaultFilteringMode':
getDefaultFilteringMode().then(beforeLevel =>
setDefaultFilteringMode(request.level).then(afterLevel =>
({ beforeLevel, afterLevel })
@@ -365,19 +369,21 @@ function onMessage(request, sender, callback) {
callback(afterLevel);
});
return true;
- }
- case 'setTrustedSites':
- setTrustedSites(request.hostnames).then(( ) => {
+ case 'getFilteringModeDetails':
+ getFilteringModeDetails(true).then(details => {
+ callback(details);
+ });
+ return true;
+
+ case 'setFilteringModeDetails':
+ setFilteringModeDetails(request.modes).then(( ) => {
registerInjectables();
- return Promise.all([
- getDefaultFilteringMode(),
- getTrustedSites(),
- ]);
- }).then(results => {
- callback({
- defaultFilteringMode: results[0],
- trustedSites: Array.from(results[1]),
+ getDefaultFilteringMode().then(defaultFilteringMode => {
+ broadcastMessage({ defaultFilteringMode });
+ });
+ getFilteringModeDetails(true).then(details => {
+ callback(details);
});
});
return true;
@@ -402,6 +408,18 @@ function onMessage(request, sender, callback) {
});
break;
+ case 'getEffectiveDynamicRules':
+ getEffectiveDynamicRules().then(result => {
+ callback(result);
+ });
+ return true;
+
+ case 'getEffectiveSessionRules':
+ getEffectiveSessionRules().then(result => {
+ callback(result);
+ });
+ return true;
+
case 'getEffectiveUserRules':
getEffectiveUserRules().then(result => {
callback(result);
diff --git a/platform/mv3/extension/js/dashboard.js b/platform/mv3/extension/js/dashboard.js
index 20f6b5ed5..d5c27078e 100644
--- a/platform/mv3/extension/js/dashboard.js
+++ b/platform/mv3/extension/js/dashboard.js
@@ -41,13 +41,13 @@ dom.on('#dashboard-nav', 'click', '.tabButton', ev => {
const { pane } = ev.target.dataset;
dom.body.dataset.pane = pane;
if ( pane === 'settings' ) {
- localRemove('activeDashboardPane');
+ localRemove('dashboard.activePane');
} else {
- localWrite('activeDashboardPane', pane);
+ localWrite('dashboard.activePane', pane);
}
});
-localRead('activeDashboardPane').then(pane => {
+localRead('dashboard.activePane').then(pane => {
if ( typeof pane !== 'string' ) { return; }
dom.body.dataset.pane = pane;
});
diff --git a/platform/mv3/extension/js/develop.js b/platform/mv3/extension/js/develop.js
index 5fdee6e29..c671384aa 100644
--- a/platform/mv3/extension/js/develop.js
+++ b/platform/mv3/extension/js/develop.js
@@ -19,871 +19,598 @@
Home: https://github.com/gorhill/uBlock
*/
-import {
- browser,
- localRead,
- localRemove,
- localWrite,
- sendMessage,
-} from './ext.js';
-import { dom, qs$ } from './dom.js';
-import { rulesFromText, textFromRules } from './dnr-parser.js';
-import { dnr } from './ext-compat.js';
-import { i18n$ } from './i18n.js';
+import { dom, qs$, qsa$ } from './dom.js';
+import { localRead, localWrite, sendMessage } from './ext.js';
+import { ModeEditor } from './mode-editor.js';
+import { ReadOnlyDNREditor } from './ro-dnr-editor.js';
+import { ReadWriteDNREditor } from './rw-dnr-editor.js';
+import { faIconsInit } from './fa-icons.js';
+import { i18n } from './i18n.js';
/******************************************************************************/
-// Details of YAML document(s) intersecting with a text span. If the text span
-// starts on a YAML document divider, the previous YAML document will be
-// included. If the text span ends on a YAML document divider, the next YAML
-// document will be included.
+class Editor {
+ constructor() {
+ this.lastSavedText = '';
+ this.view = null;
+ this.reYamlDocSeparator = /^(?:---|...)\s*$/;
+ this.modifiedRange = { start: 0, end: 0 };
+ this.updateTimer = undefined;
+ this.ioPanel = self.cm6.createViewPanel();
+ this.summaryPanel = self.cm6.createViewPanel();
+ this.panels = [];
+ }
-function snapToYamlDocument(doc, start, end) {
- let yamlDocStart = doc.lineAt(start).number;
- if ( reYamlDocSeparator.test(doc.line(yamlDocStart).text) ) {
- if ( yamlDocStart > 1 ) {
+ async init() {
+ this.editors = {
+ 'modes': new ModeEditor(this),
+ 'dnr.rw': new ReadWriteDNREditor(this),
+ 'dnr.ro': new ReadOnlyDNREditor(this),
+ };
+ const rulesetDetails = await sendMessage({ what: 'getRulesetDetails' });
+ const parent = qs$('#editors optgroup');
+ for ( const details of rulesetDetails ) {
+ const option = document.createElement('option');
+ option.value = `dnr.ro.${details.id}`;
+ option.textContent = details.name;
+ parent.append(option);
+ }
+ this.validModes = Array.from(qsa$('#editors option')).map(a => a.value);
+ const mode = await localRead('dashboard.develop.editor');
+ this.editorFromMode(mode);
+ const text = this.normalizeEditorText(await this.editor.getText(this.mode));
+ const viewConfig = {
+ text,
+ yamlLike: true,
+ oneDark: dom.cl.has(':root', 'dark'),
+ updateListener: info => { this.viewUpdateListener(info); },
+ saveListener: ( ) => { this.saveEditorText(); },
+ lineError: true,
+ spanError: true,
+ // https://codemirror.net/examples/autocompletion/
+ autocompletion: {
+ override: [
+ context => {
+ return this.autoComplete(context);
+ },
+ ],
+ activateOnCompletion: ( ) => true,
+ },
+ gutterClick: (view, info) => {
+ return this.gutterClick(view, info);
+ },
+ hoverTooltip: (view, pos, side) => {
+ return this.hoverTooltip(view, pos, side);
+ },
+ streamParser: this.streamParser,
+ foldService: (state, from) => {
+ return this.foldService(state, from);
+ },
+ readOnly: this.isReadOnly(),
+ };
+ viewConfig.panels = [ this.ioPanel, this.summaryPanel, ...this.panels ];
+ this.view = self.cm6.createEditorView(viewConfig, qs$('#cm-container'));
+ this.lastSavedText = text;
+ self.cm6.foldAll(this.view);
+ self.cm6.resetUndoRedo(this.view);
+ this.updateIOPanel();
+ this.editor.on?.(this);
+ this.modifiedRange.start = 1;
+ this.modifiedRange.end = this.view.state.doc.lines;
+ this.updateViewAsync();
+ }
+
+ normalizeEditorText(text) {
+ text ||= '';
+ text = text.trim();
+ if ( text !== '' ) { text += '\n'; }
+ return text;
+ }
+
+ setEditorText(text, saved = false) {
+ text = this.normalizeEditorText(text);
+ if ( saved ) {
+ this.lastSavedText = text;
+ }
+ this.view.dispatch({
+ changes: {
+ from: 0, to: this.view.state.doc.length,
+ insert: text,
+ },
+ });
+ this.view.focus();
+ }
+
+ getEditorText() {
+ return this.view.state.doc.toString();
+ }
+
+ editorTextChanged() {
+ const text = this.normalizeEditorText(this.getEditorText());
+ return text !== this.lastSavedText;
+ }
+
+ async selectEditor(mode) {
+ if ( mode === this.mode ) { return; }
+ this.editorFromMode(mode);
+ const text = await this.editor.getText(this.mode);
+ this.setEditorText(text);
+ this.lastSavedText = this.getEditorText();
+ self.cm6.foldAll(this.view)
+ self.cm6.resetUndoRedo(this.view);
+ self.cm6.toggleReadOnly(this.view, this.isReadOnly());
+ this.updateIOPanel();
+ this.editor.on?.(this);
+ this.modifiedRange.start = 1;
+ this.modifiedRange.end = this.view.state.doc.lines;
+ this.updateViewAsync();
+ }
+
+ editorFromMode(mode) {
+ if ( this.validModes.includes(mode) === false ) {
+ mode = 'modes';
+ }
+ if ( mode === this.mode ) { return mode; }
+ let editor;
+ if ( mode === 'modes' ) {
+ editor = this.editors['modes'];
+ } else if ( mode.startsWith('dnr.rw.') ) {
+ editor = this.editors['dnr.rw'];
+ } else if ( mode.startsWith('dnr.ro.') ) {
+ editor = this.editors['dnr.ro'];
+ } else {
+ return;
+ }
+ this.editor?.off?.(this);
+ this.editor = editor;
+ this.mode = mode;
+ const select = qs$('#editors');
+ select.value = mode;
+ }
+
+ isReadOnly() {
+ return typeof this.editor.saveEditorText !== 'function';
+ }
+
+ viewUpdateListener(info) {
+ if ( info.docChanged === false ) { return; }
+ for ( const transaction of info.transactions ) {
+ if ( transaction.docChanged === false ) { continue; }
+ this.addToModifiedRange(transaction);
+ if ( transaction.isUserEvent('delete.backward') ) {
+ this.smartBackspace(transaction);
+ } else if ( transaction.isUserEvent('input.paste') ) {
+ if ( this.editor.importFromPaste ) {
+ this.editor.importFromPaste(this, transaction);
+ }
+ } else if ( transaction.isUserEvent('input') ) {
+ if ( this.smartReturn(transaction) ) { continue; }
+ this.smartSpacebar(transaction);
+ }
+ }
+ this.updateViewAsync();
+ }
+
+ updateViewAsync() {
+ if ( this.updateTimer !== undefined ) { return; }
+ this.updateTimer = self.setTimeout(( ) => {
+ this.updateTimer = undefined;
+ this.updateView();
+ }, 71);
+ }
+
+ updateView() {
+ const { doc } = this.view.state;
+ const changed = this.editorTextChanged();
+ dom.attr('#apply', 'disabled', changed ? null : '');
+ dom.attr('#revert', 'disabled', changed ? null : '');
+ if ( typeof this.editor.updateView !== 'function' ) { return; }
+ let { start, end } = this.modifiedRange;
+ if ( start === 0 || end === 0 ) { return; }
+ this.modifiedRange.start = this.modifiedRange.end = 0;
+ if ( start > doc.lines ) { start = doc.lines; }
+ if ( end > doc.lines ) { end = doc.lines; }
+ self.cm6.lineErrorClear(this.view, start, end);
+ self.cm6.spanErrorClear(this.view, start, end);
+ const firstLine = doc.line(start);
+ const lastLine = doc.line(end);
+ this.editor.updateView(this, firstLine, lastLine);
+ }
+
+ updateIOPanel() {
+ const ioButtons = [];
+ if ( this.editor.saveEditorText ) {
+ ioButtons.push('apply', 'revert');
+ }
+ if ( this.editor.importFromFile ) {
+ ioButtons.push('import');
+ }
+ if ( this.editor.exportToFile ) {
+ ioButtons.push('export');
+ }
+ if ( ioButtons.length === 0 ) {
+ return this.ioPanel.render(this.view, null);
+ }
+ const template = document.querySelector('template.io-panel');
+ const fragment = template.content.cloneNode(true);
+ const root = fragment.querySelector('.io-panel');
+ i18n.render(root);
+ faIconsInit(root);
+ root.dataset.io = ioButtons.join(' ');
+ const config = {
+ dom: root,
+ mount: ( ) => {
+ dom.on('#apply', 'click', ( ) => {
+ this.saveEditorText();
+ });
+ dom.on('#revert', 'click', ( ) => {
+ this.revertEditorText();
+ });
+ dom.on('#import', 'click', ( ) => {
+ this.importFromFile()
+ });
+ dom.on('#export', 'click', ( ) => {
+ this.exportToFile();
+ });
+ }
+ };
+ this.ioPanel.render(this.view, config);
+ }
+
+ updateSummaryPanel(dom) {
+ if ( dom instanceof Object ) {
+ if ( this.updateSummaryPanel.timer !== undefined ) {
+ self.clearTimeout(this.updateSummaryPanel.timer);
+ this.updateSummaryPanel.timer = undefined;
+ }
+ return this.summaryPanel.render(this.view, { dom });
+ }
+ if ( this.updateSummaryPanel.timer !== undefined ) { return; }
+ this.updateSummaryPanel.timer = self.setTimeout(( ) => {
+ this.updateSummaryPanel.timer = undefined;
+ this.summaryPanel.render(this.view, null);
+ }, 157);
+ }
+
+ autoComplete(context) {
+ if ( typeof this.editor.autoComplete !== 'function' ) { return null; }
+ return this.editor.autoComplete(this, context);
+ }
+
+ hoverTooltip(view, pos, side) {
+ if ( typeof this.editor.createTooltipWidget !== 'function' ) { return null; }
+ const details = view.domAtPos(pos);
+ const textNode = details.node;
+ if ( textNode.nodeType !== 3 ) { return null; }
+ const { parentElement } = textNode;
+ const targetElement = parentElement.closest('[data-tooltip]');
+ if ( targetElement === null ) { return null; }
+ const tooltipText = targetElement.getAttribute('data-tooltip');
+ if ( Boolean(tooltipText) === false ) { return null; }
+ const start = pos - details.offset;
+ const end = start + textNode.nodeValue.length;
+ if ( start === pos && side < 0 || end === pos && side > 0 ) { return null; }
+ return {
+ above: true,
+ pos: start,
+ end,
+ create: ( ) => {
+ return { dom: this.editor.createTooltipWidget(tooltipText) };
+ },
+ };
+ }
+
+ foldService(state, from) {
+ if ( typeof this.editor.foldService !== 'function' ) { return null; }
+ return this.editor.foldService(state, from);
+ }
+
+ // Details of YAML document(s) intersecting with a text span. If the text span
+ // starts on a YAML document divider, the previous YAML document will be
+ // included. If the text span ends on a YAML document divider, the next YAML
+ // document will be included.
+
+ snapToYamlDocument(doc, start, end) {
+ let yamlDocStart = doc.lineAt(start).number;
+ if ( this.reYamlDocSeparator.test(doc.line(yamlDocStart).text) ) {
+ if ( yamlDocStart > 1 ) {
+ yamlDocStart -= 1;
+ }
+ }
+ while ( yamlDocStart > 1 ) {
+ const line = doc.line(yamlDocStart);
+ if ( this.reYamlDocSeparator.test(line.text) ) { break; }
yamlDocStart -= 1;
}
- }
- while ( yamlDocStart > 1 ) {
- const line = doc.line(yamlDocStart);
- if ( reYamlDocSeparator.test(line.text) ) { break; }
- yamlDocStart -= 1;
- }
- const lastLine = doc.lines;
- let yamlDocEnd = doc.lineAt(end).number;
- if ( reYamlDocSeparator.test(doc.line(yamlDocEnd).text) ) {
- if ( yamlDocEnd < lastLine ) {
+ const lastLine = doc.lines;
+ let yamlDocEnd = doc.lineAt(end).number;
+ if ( this.reYamlDocSeparator.test(doc.line(yamlDocEnd).text) ) {
+ if ( yamlDocEnd < lastLine ) {
+ yamlDocEnd += 1;
+ }
+ }
+ while ( yamlDocEnd < lastLine ) {
+ const line = doc.line(yamlDocEnd);
+ if ( this.reYamlDocSeparator.test(line.text) ) { break; }
yamlDocEnd += 1;
}
+ return { yamlDocStart, yamlDocEnd };
}
- while ( yamlDocEnd < lastLine ) {
- const line = doc.line(yamlDocEnd);
- if ( reYamlDocSeparator.test(line.text) ) { break; }
- yamlDocEnd += 1;
- }
- return { yamlDocStart, yamlDocEnd };
-}
-function rangeFromTransaction(transaction) {
- let from, to;
- transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
- if ( from === undefined || fromB < from ) { from = fromB; }
- if ( to === undefined || toB > to ) { to = toB; }
- });
- return { from, to };
-}
+ rangeFromTransaction(transaction) {
+ let from, to;
+ transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
+ if ( from === undefined || fromB < from ) { from = fromB; }
+ if ( to === undefined || toB > to ) { to = toB; }
+ });
+ return { from, to };
+ }
-function addToModifiedRange(transaction) {
- const { from, to } = rangeFromTransaction(transaction);
- if ( from === undefined || to === undefined ) { return; }
- const { newDoc } = transaction;
- const { yamlDocStart, yamlDocEnd } = snapToYamlDocument(newDoc, from, to);
- if ( modifiedRange.start === -1 || yamlDocStart < modifiedRange.start ) {
- modifiedRange.start = yamlDocStart;
- }
- if ( modifiedRange.end === -1 || yamlDocEnd > modifiedRange.end ) {
- modifiedRange.end = yamlDocEnd;
- }
-}
-
-const reYamlDocSeparator = /^(?:---|...)\s*$/;
-const modifiedRange = { start: -1, end: -1 };
-
-/******************************************************************************/
-
-function 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 {
- }
-}
-
-/******************************************************************************/
-
-function lineIndentAt(line) {
- const match = /^(?: {2})*/.exec(line.text);
- const indent = match !== null ? match[0].length : -1;
- if ( indent === -1 || (indent & 1) !== 0 ) { return -1; }
- return indent / 2;
-}
-
-function getScopeAt(from) {
- const { doc } = cmRules.state;
- const lineFrom = doc.lineAt(from);
- let depth = lineIndentAt(lineFrom);
- if ( depth === -1 ) { return; }
- const text = lineFrom.text.trim();
- if ( text.startsWith('#') ) { return; }
- const path = [];
- const pos = text.indexOf(':');
- if ( pos !== -1 ) {
- path.push(text.slice(0, pos+1));
- }
- let lineNo = lineFrom.number;
- while ( depth > 0 && lineNo > 1 ) {
- lineNo -= 1;
- const lineBefore = doc.line(lineNo);
- const text = lineBefore.text.trim();
- if ( text.startsWith('#') ) { continue; }
- if ( lineIndentAt(lineBefore) > (depth-1) ) { continue; }
- const match = /^- ([^:]+:)/.exec(text);
- if ( match !== null ) {
- path.unshift(match[1]);
- } else {
- path.unshift(text);
+ addToModifiedRange(transaction) {
+ const { from, to } = this.rangeFromTransaction(transaction);
+ if ( from === undefined || to === undefined ) { return; }
+ const { newDoc } = transaction;
+ const { yamlDocStart, yamlDocEnd } = this.snapToYamlDocument(newDoc, from, to);
+ if ( this.modifiedRange.start === 0 || yamlDocStart < this.modifiedRange.start ) {
+ this.modifiedRange.start = yamlDocStart;
}
- depth -= 1;
- }
- return path.join('');
-}
-
-function getAutocompleteCandidates(from) {
- const scope = 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 ' },
- ],
- };
- }
-}
-
-function autoComplete(context) {
- const match = context.matchBefore(/[\w-]*/);
- if ( match === undefined ) { return null; }
- const result = getAutocompleteCandidates(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*/,
- };
-}
-
-/******************************************************************************/
-
-function setEditorText(text) {
- if ( text === undefined ) { return; }
- if ( text !== '' ) { text += '\n'; }
- cmRules.dispatch({
- changes: {
- from: 0, to: cmRules.state.doc.length,
- insert: text,
- },
- });
- cmRules.focus();
-}
-
-function getEditorText() {
- return cmRules.state.doc.toString();
-}
-
-/******************************************************************************/
-
-function saveEditorText() {
- const text = getEditorText().trim();
- const promise = text.length !== 0
- ? localWrite('userDnrRules', text)
- : localRemove('userDnrRules');
- promise.then(( ) => {
- lastSavedText = text;
- updateView();
- }).then(( ) =>
- sendMessage({ what: 'updateUserDnrRules' })
- ).then(result => {
- if ( result instanceof Object === false ) { return; }
- updateFeedbackPanel(result);
- });
-}
-
-/******************************************************************************/
-
-async function validateRegexes(regexes) {
- if ( regexes.length === 0 ) { return; }
- const promises = regexes.map(regex => validateRegex(regex));
- await Promise.all(promises);
- for ( const regex of regexes ) {
- const i = validatedRegexes.regexes.indexOf(regex);
- if ( i === -1 ) { continue; }
- const reason = validatedRegexes.results[i];
- if ( reason === true ) { continue; }
- const entries = self.cm6.findAll(cmRules,
- `(?<=\\bregexFilter: )${RegExp.escape(regex)}`
- );
- for ( const entry of entries ) {
- self.cm6.spanErrorAdd(cmRules, entry.from, entry.to, reason);
+ if ( this.modifiedRange.end === 0 || yamlDocEnd > this.modifiedRange.end ) {
+ this.modifiedRange.end = yamlDocEnd;
}
}
-}
-async function validateRegex(regex) {
- const details = await dnr.isRegexSupported({ regex });
- const result = details.isSupported || details.reason;
- if ( validatedRegexes.regexes.length > 32 ) {
- validatedRegexes.regexes.pop();
- validatedRegexes.results.pop();
+ lineIndentAt(line) {
+ const match = /^(?: {2})*/.exec(line.text);
+ const indent = match !== null ? match[0].length : -1;
+ if ( indent === -1 || (indent & 1) !== 0 ) { return -1; }
+ return indent / 2;
}
- validatedRegexes.regexes.unshift(regex);
- validatedRegexes.results.unshift(result);
-}
-const validatedRegexes = {
- regexes: [],
- results: [],
-};
-
-/******************************************************************************/
-
-function updateView() {
- const { doc } = cmRules.state;
- const changed = doc.toString().trim() !==
- lastSavedText.trim();
- dom.attr('#dnrRulesApply', 'disabled', changed ? null : '');
- dom.attr('#dnrRulesRevert', 'disabled', changed ? null : '');
- const { start, end } = modifiedRange;
- if ( start === -1 || end === -1 ) { return; }
- modifiedRange.start = modifiedRange.end = -1;
- self.cm6.lineErrorClear(cmRules, start, end);
- self.cm6.spanErrorClear(cmRules, start, end);
- const firstLine = doc.line(start);
- const lastLine = doc.line(end);
- const text = doc.sliceString(firstLine.from, lastLine.to);
- const { bad } = rulesFromText(text);
- if ( Array.isArray(bad) && bad.length !== 0 ) {
- self.cm6.lineErrorAdd(cmRules, bad.map(i => i + start));
- }
- const entries = self.cm6.findAll(
- cmRules,
- '\\bregexFilter: (\\S+)',
- firstLine.from,
- lastLine.to
- );
- const regexes = [];
- for ( const entry of entries ) {
- const regex = entry.match[1];
- const i = validatedRegexes.regexes.indexOf(regex);
- if ( i !== -1 ) {
- const reason = validatedRegexes.results[i];
- if ( reason === true ) { continue; }
- self.cm6.spanErrorAdd(cmRules, entry.from+13, entry.to, reason);
- } else {
- regexes.push(regex);
+ getScopeAt(from, doc) {
+ doc ||= this.view.state.doc;
+ const lineFrom = doc.lineAt(from);
+ const out = {};
+ let depth = this.lineIndentAt(lineFrom);
+ if ( depth === -1 ) { return out; }
+ const text = lineFrom.text.trim();
+ if ( text.startsWith('#') ) { return out; }
+ const path = [];
+ const pos = text.indexOf(':');
+ if ( pos !== -1 ) {
+ path.push(text.slice(0, pos+1));
}
- }
- validateRegexes(regexes);
-}
-
-function updateViewAsync() {
- if ( updateViewAsync.timer !== undefined ) { return; }
- updateViewAsync.timer = self.setTimeout(( ) => {
- updateViewAsync.timer = undefined;
- updateView();
- }, 71);
-}
-
-/******************************************************************************/
-
-function updateSummaryPanel(info) {
- self.cm6.showSummaryPanel(cmRules, {
- template: '.summary-panel',
- text: i18n$('dnrRulesCountInfo')
- .replace('{count}', (info.userDnrRuleCount || 0).toLocaleString()),
- });
-}
-
-function updateFeedbackPanel(info) {
- const errors = [];
- if ( Array.isArray(info.errors) ) {
- info.errors.forEach(e => errors.push(e));
- }
- const text = errors.join('\n');
- self.cm6.showFeedbackPanel(cmRules, { template: '.feedback-panel', text });
-}
-
-/******************************************************************************/
-
-function importRulesFromFile() {
- const input = qs$('input[type="file"]');
- input.onchange = ev => {
- input.onchange = null;
- const file = ev.target.files[0];
- if ( file === undefined || file.name === '' ) { return; }
- if ( file.type !== 'application/json' ) { return; }
- const fr = new FileReader();
- fr.onload = ( ) => {
- if ( typeof fr.result !== 'string' ) { return; }
- const rules = rulesFromJSON(fr.result);
- if ( rules === undefined ) { return; }
- const text = textFromRules(rules);
- if ( text === undefined ) { return; }
- const { doc } = cmRules.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';
+ let lineNo = lineFrom.number;
+ while ( depth > 0 && lineNo > 1 ) {
+ lineNo -= 1;
+ const lineBefore = doc.line(lineNo);
+ const text = lineBefore.text.trim();
+ if ( text.startsWith('#') ) { continue; }
+ if ( this.lineIndentAt(lineBefore) > (depth-1) ) { continue; }
+ const match = /^- ([^:]+:)/.exec(text);
+ if ( match !== null ) {
+ path.unshift(match[1]);
} else {
- from = lastLine.from;
+ path.unshift(text);
}
- if ( /(?:^|\n)---$/.test(lastChars) === false ) {
- prepend = `${prepend}---\n`;
+ depth -= 1;
+ }
+ out.scope = path.join('');
+ out.depth = path.length;
+ return out;
+ }
+
+ async saveEditorText() {
+ if ( typeof this.editor.saveEditorText !== 'function' ) { return; }
+ if ( this.editorTextChanged() === false ) { return; }
+ const saved = await this.editor.saveEditorText(this);
+ if ( saved !== true ) { return; }
+ this.lastSavedText = this.normalizeEditorText(this.getEditorText());
+ this.updateView();
+ }
+
+ revertEditorText() {
+ if ( this.editorTextChanged() === false ) { return; }
+ this.setEditorText(this.lastSavedText);
+ }
+
+ smartBackspace(transaction) {
+ const { from, to } = this.rangeFromTransaction(transaction);
+ if ( from === undefined || to === undefined ) { return; }
+ if ( to !== from ) { return; }
+ const { newDoc } = transaction;
+ const line = newDoc.lineAt(from);
+ if ( /^(?: {2})+-$/.test(line.text) === false ) { return; }
+ this.view.dispatch({ changes: { from: from-3, to: from, insert: '' } });
+ return true;
+ }
+
+ lineIsArrayItem(doc, lineNo) {
+ if ( lineNo < 1 || lineNo > doc.lines ) { return false; }
+ const line = doc.line(lineNo);
+ return /^(?: {2})+- /.test(line.text);
+ }
+
+ smartArrayItem(doc, from) {
+ const line = doc.lineAt(from);
+ const blanks = /^ *$/.exec(line.text);
+ if ( blanks === null ) { return; }
+ const lineNumberBefore = line.number - 1;
+ let targetIndent;
+ if ( this.lineIsArrayItem(doc, line.number-1) ) {
+ targetIndent = doc.line(line.number-1).text.indexOf('- ');
+ } else if ( this.lineIsArrayItem(doc, line.number+1) ) {
+ targetIndent = doc.line(line.number+1).text.indexOf('- ');
+ } else if ( this.editor.sequenceScopes && lineNumberBefore !== 0 ) {
+ const lineBefore = doc.line(lineNumberBefore);
+ const { scope, depth } = this.getScopeAt(lineBefore.to, doc);
+ if ( this.editor.sequenceScopes.includes(scope) ) {
+ targetIndent = depth * 2;
}
- cmRules.dispatch({ changes: { from, insert: `${prepend}${text}` } });
- self.cm6.foldAll(cmRules);
- cmRules.focus();
+ }
+ if ( targetIndent === undefined ) { return; }
+ const indent = targetIndent - blanks[0].length;
+ if ( indent < 0 || indent > 2 ) { return; }
+ return `${' '.repeat(indent)}- `;
+ }
+
+ smartReturn(transaction) {
+ const { from, to } = this.rangeFromTransaction(transaction);
+ if ( from === undefined || to === undefined ) { return; }
+ const { newDoc } = transaction;
+ const insert = this.smartArrayItem(newDoc, to);
+ if ( Boolean(insert) === false ) { return; }
+ this.view.dispatch({
+ changes: { from: to, insert },
+ selection: { anchor: to + insert.length },
+ });
+ return true;
+ }
+
+ smartSpacebar(transaction) {
+ const { from, to } = this.rangeFromTransaction(transaction);
+ if ( from === undefined || to === undefined ) { return; }
+ if ( (to - from) !== 1 ) { return; }
+ const { newDoc } = transaction;
+ const line = newDoc.lineAt(to);
+ const localTo = to - line.from;
+ const before = line.text.slice(0, localTo);
+ if ( /^(?: {1}| {3})$/.test(before) === false ) { return; }
+ const insert = this.smartArrayItem(newDoc, to) || ' ';
+ this.view.dispatch({
+ changes: { from: to, insert },
+ selection: { anchor: to + insert.length },
+ });
+ return true;
+ }
+
+ gutterClick(view, info) {
+ const reSeparator = /^---\s*/;
+ const { doc } = view.state;
+ const lineFirst = doc.lineAt(info.from);
+ if ( lineFirst.text === '' ) { return false; }
+ let { from, to } = lineFirst;
+ if ( reSeparator.test(lineFirst.text) ) {
+ let lineNo = lineFirst.number + 1;
+ while ( lineNo < doc.lines ) {
+ const line = doc.line(lineNo);
+ if ( reSeparator.test(line.text) ) { break; }
+ to = line.to;
+ lineNo += 1;
+ }
+ }
+ view.dispatch({
+ selection: { anchor: from, head: to+1 }
+ });
+ view.focus();
+ return true;
+ }
+
+ importFromFile() {
+ const editor = this.editor;
+ if ( typeof editor.importFromFile !== 'function' ) { return; }
+ const input = qs$('input[type="file"]');
+ input.accept = editor.ioAccept || '';
+ input.onchange = ev => {
+ input.onchange = null;
+ const file = ev.target.files[0];
+ if ( file === undefined || file.name === '' ) { return; }
+ const fr = new FileReader();
+ fr.onload = ( ) => {
+ if ( typeof fr.result !== 'string' ) { return; }
+ editor.importFromFile(this, fr.result);
+ };
+ fr.readAsText(file);
};
- fr.readAsText(file);
- };
- // Reset to empty string, this will ensure a change event is properly
- // triggered if the user pick a file, even if it's the same as the last
- // one picked.
- input.value = '';
- input.click();
-}
-
-/******************************************************************************/
-
-function exportRulesToFile() {
- const text = getEditorText();
- const { rules } = rulesFromText(text);
- if ( Array.isArray(rules) === false ) { return; }
- let ruleId = 1;
- for ( const rule of rules ) {
- rule.id = ruleId++;
+ // Reset to empty string, this will ensure a change event is properly
+ // triggered if the user pick a file, even if it's the same as the last
+ // one picked.
+ input.value = '';
+ input.click();
}
- const filename = 'my-ubol-dnr-rules.json';
- const a = document.createElement('a');
- a.href = `data:application/json;charset=utf-8,${JSON.stringify(rules, null, 2)}`;
- dom.attr(a, 'download', filename || '');
- dom.attr(a, 'type', 'application/json');
- a.click();
-}
-/******************************************************************************/
-
-function importRulesFromPaste(transaction) {
- const { from, to } = 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
- if ( lineFrom.number !== 1 ) {
- const lineBefore = newDoc.line(lineFrom.number-1);
- if ( /^---\s*$/.test(lineBefore.text) === false ) { return; }
+ exportToFile() {
+ const editor = this.editor;
+ if ( typeof editor.exportToFile !== 'function' ) { return; }
+ const text = this.getEditorText();
+ const result = editor.exportToFile(text);
+ if ( result === undefined ) { return; }
+ const { fname, data, mime } = result;
+ const a = document.createElement('a');
+ a.href = `data:${mime};charset=utf-8,${encodeURIComponent(data)}`;
+ dom.attr(a, 'download', fname || '');
+ dom.attr(a, 'type', mime);
+ a.click();
}
- const pastedText = newDoc.sliceString(from, to);
- const rules = rulesFromJSON(pastedText);
- if ( rules === undefined ) { return; }
- const yamlText = textFromRules(rules);
- if ( yamlText === undefined ) { return; }
- cmRules.dispatch({ changes: { from, to, insert: yamlText } });
- self.cm6.foldAll(cmRules);
- return true;
-}
-/******************************************************************************/
-
-function foldService(state, from) {
- const { doc } = state;
- const lineFrom = doc.lineAt(from);
- if ( reFoldable.test(lineFrom.text) === false ) { return null; }
- if ( lineFrom.number <= 5 ) { return null ; }
- const lineBlockStart = doc.line(lineFrom.number - 5);
- if ( reFoldCandidates.test(lineBlockStart.text) === false ) { return null; }
- for ( let i = lineFrom.number-4; i < lineFrom.number; i++ ) {
- const line = doc.line(i);
- if ( reFoldable.test(line.text) === false ) { return null; }
- }
- let i = lineFrom.number + 1;
- for ( ; i <= doc.lines; i++ ) {
- const lineNext = doc.line(i);
- if ( reFoldable.test(lineNext.text) === false ) { break; }
- }
- i -= 1;
- if ( i === lineFrom.number ) { return null; }
- const lineFoldEnd = doc.line(i);
- return { from: lineFrom.from+6, to: lineFoldEnd.to };
-}
-
-const reFoldable = /^ {4}- \S/;
-const reFoldCandidates = new RegExp(`^(?: {2})+${[
- 'initiatorDomains',
- 'excludedInitiatorDomains',
- 'requestDomains',
- 'excludedRequestDomains',
-].join('|')}:$`);
-
-/******************************************************************************/
-
-function smartBackspace(transaction) {
- const { from, to } = rangeFromTransaction(transaction);
- if ( from === undefined || to === undefined ) { return; }
- if ( to !== from ) { return; }
- const { newDoc } = transaction;
- const line = newDoc.lineAt(from);
- if ( /^(?: {2})+-$/.test(line.text) === false ) { return; }
- cmRules.dispatch({ changes: { from: from-3, to: from, insert: '' } });
- return true;
-}
-
-/******************************************************************************/
-
-function lineIsArrayItem(doc, lineNo) {
- if ( lineNo < 1 || lineNo > doc.lines ) { return false; }
- const line = doc.line(lineNo);
- return line.text.startsWith(' - ');
-}
-
-/******************************************************************************/
-
-function smartArrayItem(doc, from) {
- const line = doc.lineAt(from);
- if ( lineIsArrayItem(doc, line.number-1) === false ) {
- if ( lineIsArrayItem(doc, line.number+1) === false ) { return ''; }
- }
- const blanks = /^ {2,4}$/.exec(line.text);
- if ( blanks === null ) { return ''; }
- const count = blanks[0].length;
- return `${' '.repeat(4-count)}- `;
-}
-
-/******************************************************************************/
-
-function smartReturn(transaction) {
- const { from, to } = rangeFromTransaction(transaction);
- if ( from === undefined || to === undefined ) { return; }
- const { newDoc } = transaction;
- const insert = smartArrayItem(newDoc, to);
- if ( insert === '' ) { return; }
- cmRules.dispatch({
- changes: { from: to, insert },
- selection: { anchor: to + insert.length },
- });
- return true;
-}
-
-/******************************************************************************/
-
-function smartSpacebar(transaction) {
- const { from, to } = rangeFromTransaction(transaction);
- if ( from === undefined || to === undefined ) { return; }
- if ( (to - from) !== 1 ) { return; }
- const { newDoc } = transaction;
- const line = newDoc.lineAt(to);
- const localTo = to - line.from;
- const before = line.text.slice(0, localTo);
- if ( /^(?: {1}| {3})$/.test(before) === false ) { return; }
- const insert = smartArrayItem(newDoc, to) || ' ';
- cmRules.dispatch({
- changes: { from: to, insert },
- selection: { anchor: to + insert.length },
- });
- return true;
-}
-
-/******************************************************************************/
-
-const dnryamlStreamParser = {
- name: 'dnryaml',
- startState() {
- return {
- scope: 0,
- reKeywords: new RegExp(`\\b(${[
- 'block',
- 'redirect',
- 'allow',
- 'modifyHeaders',
- 'upgradeScheme',
- 'allowAllRequest',
- 'append',
- 'set',
- 'remove',
- 'firstParty',
- 'thirdParty',
- 'true',
- 'false',
- 'connect',
- 'delete',
- 'get',
- 'head',
- 'options',
- 'patch',
- 'post',
- 'put',
- 'other',
- 'main_frame',
- 'sub_frame',
- 'stylesheet',
- 'script',
- 'image',
- 'font',
- 'object',
- 'xmlhttprequest',
- 'ping',
- 'csp_report',
- 'media',
- 'websocket',
- 'webtransport',
- 'webbundle',
- 'other',
- ].join('|')})\\b`),
- };
- },
- token(stream, state) {
- const c = stream.peek();
- if ( c === '#' ) {
- if ( (stream.pos === 0 || /\s/.test(stream.string.charAt(stream.pos - 1))) ) {
- stream.skipToEnd();
- return 'comment';
+ streamParser = {
+ startState: ( ) => {
+ return { scope: 0 };
+ },
+ token: (stream, state) => {
+ const c = stream.peek();
+ if ( c === '#' ) {
+ if ( (stream.pos === 0 || /\s/.test(stream.string.charAt(stream.pos - 1))) ) {
+ stream.skipToEnd();
+ return 'comment';
+ }
}
- }
- if ( stream.sol() ) {
- if ( stream.match('---') ) { return 'contentSeparator'; }
- if ( stream.match('...') ) { return 'contentSeparator'; }
- }
- if ( stream.eatSpace() ) {
+ if ( stream.sol() ) {
+ if ( stream.match(/---\s*$/) ) { return 'meta'; }
+ if ( stream.match(/\.\.\.\s*$/) ) { return 'meta'; }
+ }
+ if ( stream.eatSpace() ) {
+ return null;
+ }
+ const { scope } = state;
+ state.scope = 0;
+ if ( scope === 0 && stream.match(/^[^:]+(?=:)/) ) {
+ state.scope = 1;
+ return 'keyword';
+ }
+ if ( scope === 1 && stream.match(/^:(?: |$)/) ) {
+ return 'punctuation';
+ }
+ if ( stream.match(/^- /) ) {
+ return 'punctuation';
+ }
+ if ( this.editor.streamParserKeywords ) {
+ if ( stream.match(this.editor.streamParserKeywords) ) {
+ return 'literal';
+ }
+ }
+ if ( stream.match(/^\S+/) ) {
+ return null;
+ }
+ stream.next();
return null;
- }
- const { scope } = state;
- state.scope = 0;
- if ( scope === 0 && stream.match(/^[^:]+(?=:)/) ) {
- state.scope = 1;
- return 'keyword';
- }
- if ( scope === 1 && stream.match(/^:(?: |$)/) ) {
- return 'meta';
- }
- if ( stream.match(/^- /) ) {
- return 'meta';
- }
- if ( stream.match(state.reKeywords) ) {
- return 'literal';
- }
- if ( stream.match(/^\S+/) ) {
- return null;
- }
- stream.next();
- return null;
- },
-};
-
-/******************************************************************************/
-
-function cmUpdateListener(info) {
- if ( info.docChanged === false ) { return; }
- for ( const transaction of info.transactions ) {
- if ( transaction.docChanged === false ) { continue; }
- addToModifiedRange(transaction);
- if ( transaction.isUserEvent('delete.backward') ) {
- smartBackspace(transaction);
- } else if ( transaction.isUserEvent('input.paste') ) {
- importRulesFromPaste(transaction);
- } else if ( transaction.isUserEvent('input') ) {
- if ( smartReturn(transaction) ) { continue; }
- smartSpacebar(transaction);
- }
- }
- updateViewAsync();
-}
-
-/******************************************************************************/
-
-function gutterClick(view, info) {
- const reSeparator = /^---\s*/;
- const { doc } = view.state;
- const lineFirst = doc.lineAt(info.from);
- if ( lineFirst.text === '' ) { return false; }
- let { from, to } = lineFirst;
- if ( reSeparator.test(lineFirst.text) ) {
- let lineNo = lineFirst.number + 1;
- while ( lineNo < doc.lines ) {
- const line = doc.line(lineNo);
- if ( reSeparator.test(line.text) ) { break; }
- to = line.to;
- lineNo += 1;
- }
- }
- view.dispatch({
- selection: { anchor: from, head: to+1 }
- });
- view.focus();
- return true;
-}
-
-/******************************************************************************/
-
-function hoverTooltip(view, pos, side) {
- const details = view.domAtPos(pos);
- const textNode = details.node;
- if ( textNode.nodeType !== 3 ) { return null; }
- const { parentElement } = textNode;
- const targetElement = parentElement.closest('[data-tooltip]');
- if ( targetElement === null ) { return null; }
- const tooltipText = targetElement.getAttribute('data-tooltip');
- if ( Boolean(tooltipText) === false ) { return null; }
- const start = pos - details.offset;
- const end = start + textNode.nodeValue.length;
- if ( start === pos && side < 0 || end === pos && side > 0 ) { return null; }
- return {
- above: true,
- pos: start,
- end,
- create() {
- const template = document.querySelector('.badmark-tooltip');
- const fragment = template.content.cloneNode(true);
- const dom = fragment.querySelector('.badmark-tooltip');
- dom.textContent = tooltipText;
- return { dom };
},
};
+
}
/******************************************************************************/
-let lastSavedText = '';
-
-const cmRules = await localRead('userDnrRules').then(text => {
- text ||= '';
-
- const view = self.cm6.createEditorView({
- text,
- dnrRules: true,
- oneDark: dom.cl.has(':root', 'dark'),
- updateListener: cmUpdateListener,
- saveListener: ( ) => {
- saveEditorText();
- },
- lineError: true,
- spanError: true,
- // https://codemirror.net/examples/autocompletion/
- autocompletion: {
- override: [ autoComplete ],
- activateOnCompletion: ( ) => true,
- },
- gutterClick,
- hoverTooltip,
- streamParser: dnryamlStreamParser,
- foldService,
- }, qs$('#cm-dnrRules'));
-
- lastSavedText = text;
- self.cm6.foldAll(view);
- self.cm6.resetUndoRedo(view);
-
- browser.storage.onChanged.addListener((changes, area) => {
- if ( area !== 'local' ) { return; }
- const { userDnrRuleCount } = changes;
- if ( userDnrRuleCount instanceof Object === false ) { return; }
- const { newValue } = changes.userDnrRuleCount;
- updateSummaryPanel({ userDnrRuleCount: newValue });
+async function start() {
+ const editor = new Editor();
+ await editor.init();
+ dom.on('#editors', 'change', ( ) => {
+ const select = qs$('#editors');
+ const mode = select.value;
+ if ( mode === editor.mode ) { return; }
+ editor.selectEditor(mode);
+ localWrite('dashboard.develop.editor', editor.mode);
});
+}
- localRead('userDnrRuleCount').then(userDnrRuleCount => {
- updateSummaryPanel({ userDnrRuleCount })
- });
-
- dom.on('#dnrRulesApply', 'click', ( ) => {
- saveEditorText();
- });
- dom.on('#dnrRulesRevert', 'click', ( ) => {
- setEditorText(lastSavedText);
- sendMessage({ what: 'updateUserDnrRules' });
- });
- dom.on('#dnrRulesImport', 'click', importRulesFromFile);
- dom.on('#dnrRulesExport', 'click', exportRulesToFile);
-
- return view;
+let observer = new IntersectionObserver(entries => {
+ for ( const entry of entries ) {
+ if ( entry.isIntersecting === false ) { continue; }
+ start();
+ observer.disconnect();
+ observer = null;
+ break;
+ }
});
+observer.observe(qs$('section[data-pane="develop"]'));
/******************************************************************************/
diff --git a/platform/mv3/extension/js/dnr-editor.js b/platform/mv3/extension/js/dnr-editor.js
new file mode 100644
index 000000000..4600d552e
--- /dev/null
+++ b/platform/mv3/extension/js/dnr-editor.js
@@ -0,0 +1,180 @@
+/*******************************************************************************
+
+ 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 { dnr } from './ext-compat.js';
+import { rulesFromText } from './dnr-parser.js';
+
+/******************************************************************************/
+
+export class DNREditor {
+ constructor() {
+ this.validatedRegexes = [];
+ this.validatedRegexResults = [];
+ }
+
+ updateView(editor, firstLine, lastLine) {
+ const { doc } = editor.view.state;
+ const text = doc.sliceString(firstLine.from, lastLine.to);
+ const { bad } = rulesFromText(text);
+ if ( Array.isArray(bad) && bad.length !== 0 ) {
+ self.cm6.lineErrorAdd(editor.view, bad.map(i => i + firstLine.number));
+ }
+ const entries = self.cm6.findAll(
+ editor.view,
+ '\\bregexFilter: (\\S+)',
+ firstLine.from,
+ lastLine.to
+ );
+ const regexes = [];
+ for ( const entry of entries ) {
+ const regex = entry.match[1];
+ const i = this.validatedRegexes.indexOf(regex);
+ if ( i !== -1 ) {
+ const reason = this.validatedRegexResults[i];
+ if ( reason === true ) { continue; }
+ self.cm6.spanErrorAdd(editor.view, entry.from+13, entry.to, reason);
+ } else {
+ regexes.push(regex);
+ }
+ }
+ this.validateRegexes(editor, regexes);
+ }
+
+ exportToFile(text, fname) {
+ const { rules } = rulesFromText(text);
+ if ( Array.isArray(rules) === false ) { return; }
+ let ruleId = 1;
+ for ( const rule of rules ) {
+ rule.id = ruleId++;
+ }
+ return {
+ fname,
+ data: JSON.stringify(rules, null, 2),
+ mime: 'application/json',
+ };
+ }
+
+ async validateRegexes(editor, regexes) {
+ if ( regexes.length === 0 ) { return; }
+ const promises = regexes.map(regex => this.validateRegex(regex));
+ await Promise.all(promises);
+ for ( const regex of regexes ) {
+ const i = this.validatedRegexes.indexOf(regex);
+ if ( i === -1 ) { continue; }
+ const reason = this.validatedRegexResults[i];
+ if ( reason === true ) { continue; }
+ const entries = self.cm6.findAll(editor.view,
+ `(?<=\\bregexFilter: )${RegExp.escape(regex)}`
+ );
+ for ( const entry of entries ) {
+ self.cm6.spanErrorAdd(editor.view, entry.from, entry.to, reason);
+ }
+ }
+ }
+
+ async validateRegex(regex) {
+ const details = await dnr.isRegexSupported({ regex });
+ const result = details.isSupported || details.reason;
+ if ( this.validatedRegexes.length > 32 ) {
+ this.validatedRegexes.pop();
+ this.validatedRegexResults.pop();
+ }
+ this.validatedRegexes.unshift(regex);
+ this.validatedRegexResults.unshift(result);
+ }
+
+ createTooltipWidget(text) {
+ const template = document.querySelector('.badmark-tooltip');
+ const fragment = template.content.cloneNode(true);
+ const dom = fragment.querySelector('.badmark-tooltip');
+ dom.textContent = text;
+ return dom;
+ }
+
+ foldService(state, from) {
+ const { doc } = state;
+ const lineFrom = doc.lineAt(from);
+ if ( this.reFoldable.test(lineFrom.text) === false ) { return null; }
+ if ( lineFrom.number <= 5 ) { return null ; }
+ const lineBlockStart = doc.line(lineFrom.number - 5);
+ if ( this.reFoldCandidates.test(lineBlockStart.text) === false ) { return null; }
+ for ( let i = lineFrom.number-4; i < lineFrom.number; i++ ) {
+ const line = doc.line(i);
+ if ( this.reFoldable.test(line.text) === false ) { return null; }
+ }
+ let i = lineFrom.number + 1;
+ for ( ; i <= doc.lines; i++ ) {
+ const lineNext = doc.line(i);
+ if ( this.reFoldable.test(lineNext.text) === false ) { break; }
+ }
+ i -= 1;
+ if ( i === lineFrom.number ) { return null; }
+ const lineFoldEnd = doc.line(i);
+ return { from: lineFrom.from+6, to: lineFoldEnd.to };
+ }
+ reFoldable = /^ {4}- \S/;
+ reFoldCandidates = new RegExp(`^(?: {2})+${[
+ 'initiatorDomains',
+ 'excludedInitiatorDomains',
+ 'requestDomains',
+ 'excludedRequestDomains',
+ ].join('|')}:$`);
+
+ streamParserKeywords = new RegExp(`\\b(${[
+ 'block',
+ 'redirect',
+ 'allow',
+ 'modifyHeaders',
+ 'upgradeScheme',
+ 'allowAllRequest',
+ 'append',
+ 'set',
+ 'remove',
+ 'firstParty',
+ 'thirdParty',
+ 'true',
+ 'false',
+ 'connect',
+ 'delete',
+ 'get',
+ 'head',
+ 'options',
+ 'patch',
+ 'post',
+ 'put',
+ 'other',
+ 'main_frame',
+ 'sub_frame',
+ 'stylesheet',
+ 'script',
+ 'image',
+ 'font',
+ 'object',
+ 'xmlhttprequest',
+ 'ping',
+ 'csp_report',
+ 'media',
+ 'websocket',
+ 'webtransport',
+ 'webbundle',
+ 'other',
+ ].join('|')})\\b`);
+};
diff --git a/platform/mv3/extension/js/dnr-parser.js b/platform/mv3/extension/js/dnr-parser.js
index a852a5f03..37e2d227b 100644
--- a/platform/mv3/extension/js/dnr-parser.js
+++ b/platform/mv3/extension/js/dnr-parser.js
@@ -94,9 +94,15 @@ const perScopeParsers = {
rule[key] = {};
scope.push(key);
break;
+ case 'id': {
+ const n = parseInt(val, 10);
+ if ( isNaN(n) || n < 1) { return false; }
+ rule.id = n;
+ break;
+ }
case 'priority': {
const n = parseInt(val, 10);
- if ( isNaN(n) || n <= 1 ) { return false; }
+ if ( isNaN(n) || n < 1 ) { return false; }
rule.priority = n;
break;
}
@@ -283,6 +289,10 @@ const perScopeParsers = {
rule.condition[key] = [];
scope.push(key);
break;
+ case 'tabIds':
+ rule.condition.tabIds = [];
+ scope.push('tabIds');
+ break;
default:
return false;
}
@@ -412,6 +422,12 @@ const perScopeParsers = {
item.excludedValues.push(node.val);
return true;
},
+ 'condition.tabIds': function(scope, rule, node) {
+ if ( node.list !== true ) { return false; }
+ const n = parseInt(node.val, 10);
+ if ( isNaN(n) || n === 0 ) { return false; }
+ rule.condition.tabIds.push(n);
+ },
};
/******************************************************************************/
@@ -479,16 +495,17 @@ export function rulesFromText(text) {
const indices = [];
for ( let i = 0; i < lines.length; i++ ) {
const line = lines[i].trimEnd();
- const trimmed = line.trimStart();
- if ( trimmed.startsWith('#') ) { continue; }
- // Discard leading empty lines
- if ( trimmed === '' ) {
- if ( indices.length === 0 ) { continue; }
- }
+ if ( line.trim().startsWith('#') ) { continue; }
if ( line !== '---' && line !== '...' ) {
indices.push(i);
continue;
}
+ // Discard leading empty lines
+ while ( indices.length !== 0 ) {
+ const s = lines[indices[0]].trim();
+ if ( s.length !== 0 ) { break; }
+ indices.shift();
+ }
// Discard trailing empty lines
while ( indices.length !== 0 ) {
const s = lines[indices.at(-1)].trim();
@@ -544,18 +561,21 @@ function textFromValue(val, depth) {
/******************************************************************************/
-export function textFromRules(rules) {
+export function textFromRules(rules, option = {}) {
if ( Array.isArray(rules) === false ) {
if ( rules instanceof Object === false ) { return; }
rules = [ rules ];
}
const out = [];
for ( const rule of rules ) {
- if ( rule.id ) { rule.id = undefined };
+ if ( option.keepId !== true && rule.id ) { rule.id = undefined };
const text = textFromValue(rule, 0);
if ( text === undefined ) { continue; }
out.push(text, '---' );
}
- out.push('');
+ if ( out.length !== 0 ) {
+ out.unshift('---');
+ out.push('');
+ }
return out.join('\n');
}
diff --git a/platform/mv3/extension/js/mode-editor.js b/platform/mv3/extension/js/mode-editor.js
new file mode 100644
index 000000000..3fc527eff
--- /dev/null
+++ b/platform/mv3/extension/js/mode-editor.js
@@ -0,0 +1,76 @@
+/*******************************************************************************
+
+ 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 {
+ modesFromText,
+ textFromModes,
+} from './mode-parser.js';
+import { i18n$ } from './i18n.js';
+import { sendMessage } from './ext.js';
+
+/******************************************************************************/
+
+export class ModeEditor {
+ constructor(editor) {
+ this.editor = editor;
+ this.bc = new self.BroadcastChannel('uBOL');
+ this.bc.onmessage = ev => {
+ const message = ev.data;
+ if ( message instanceof Object === false ) { return; }
+ if ( message.filteringModeDetails === undefined ) { return; }
+ // TODO: merge with ongoing edits?
+ const text = textFromModes(message.filteringModeDetails);
+ editor.setEditorText(text, true);
+ };
+ }
+
+ async getText() {
+ const modes = await sendMessage({ what: 'getFilteringModeDetails' });
+ return textFromModes(modes);
+ }
+
+ async saveEditorText(editor) {
+ const { modes } = modesFromText(editor.getEditorText());
+ if ( modes instanceof Object === false ) { return; }
+ const modesAfter = await sendMessage({ what: 'setFilteringModeDetails', modes });
+ const text = textFromModes(modesAfter);
+ editor.setEditorText(text);
+ return true;
+ }
+
+ updateView(editor, firstLine, lastLine) {
+ const { doc } = editor.view.state;
+ const text = doc.sliceString(firstLine.from, lastLine.to);
+ const { bad } = modesFromText(text, true);
+ if ( Array.isArray(bad) && bad.length !== 0 ) {
+ self.cm6.lineErrorAdd(editor.view, bad.map(i => i + firstLine.number));
+ }
+ }
+
+ sequenceScopes = [
+ `${i18n$('filteringMode0Name')}:`, 'none:',
+ `${i18n$('filteringMode1Name')}:`, 'basic:',
+ `${i18n$('filteringMode2Name')}:`, 'optimal:',
+ `${i18n$('filteringMode3Name')}:`, 'complete:',
+ ];
+
+ ioAccept = '.json,application/json';
+};
diff --git a/platform/mv3/extension/js/mode-manager.js b/platform/mv3/extension/js/mode-manager.js
index 6affead57..ae139a825 100644
--- a/platform/mv3/extension/js/mode-manager.js
+++ b/platform/mv3/extension/js/mode-manager.js
@@ -225,7 +225,7 @@ export async function readFilteringModeDetails(bypassCache = false) {
}
}
let [
- userModes,
+ userModes = { optimal: [ 'all-urls' ] },
adminDefaultFiltering,
adminNoFiltering,
] = await Promise.all([
@@ -233,9 +233,6 @@ export async function readFilteringModeDetails(bypassCache = false) {
adminReadEx('defaultFiltering'),
adminReadEx('noFiltering'),
]);
- if ( userModes === undefined ) {
- userModes = { optimal: [ 'all-urls' ] };
- }
userModes = unserializeModeDetails(userModes);
if ( adminDefaultFiltering !== undefined ) {
const modefromName = {
@@ -277,29 +274,34 @@ async function writeFilteringModeDetails(afterDetails) {
readFilteringModeDetails.cache = unserializeModeDetails(data);
return Promise.all([
getDefaultFilteringMode(),
- getTrustedSites(),
hasBroadHostPermissions(),
localWrite('filteringModeDetails', data),
sessionWrite('filteringModeDetails', data),
]).then(results => {
broadcastMessage({
defaultFilteringMode: results[0],
- trustedSites: Array.from(results[1]),
- hasOmnipotence: results[2],
+ hasOmnipotence: results[1],
+ filteringModeDetails: readFilteringModeDetails.cache,
});
});
}
/******************************************************************************/
-export async function getFilteringModeDetails() {
+export async function getFilteringModeDetails(serializable = false) {
const actualDetails = await readFilteringModeDetails();
- return {
+ const out = {
none: new Set(actualDetails.none),
basic: new Set(actualDetails.basic),
optimal: new Set(actualDetails.optimal),
complete: new Set(actualDetails.complete),
};
+ return serializable ? serializeModeDetails(out) : out;
+}
+
+export async function setFilteringModeDetails(details) {
+ await localWrite('filteringModeDetails', serializeModeDetails(details));
+ await readFilteringModeDetails(true);
}
/******************************************************************************/
@@ -328,45 +330,6 @@ export function setDefaultFilteringMode(afterLevel) {
/******************************************************************************/
-export async function getTrustedSites() {
- const filteringModes = await getFilteringModeDetails();
- return filteringModes.none;
-}
-
-export async function setTrustedSites(hostnames) {
- const [
- filteringModes,
- hasOmnipotence,
- ] = await Promise.all([
- getFilteringModeDetails(),
- hasBroadHostPermissions(),
- ]);
- const { none } = filteringModes;
- const hnSet = new Set(hostnames);
- let modified = false;
- if ( none.has('all-urls') && hnSet.has('all-urls') === false ) {
- applyFilteringMode(filteringModes, 'all-urls', hasOmnipotence ? MODE_OPTIMAL : MODE_BASIC);
- modified = true;
- }
- for ( const hn of none ) {
- if ( hnSet.has(hn) ) {
- hnSet.delete(hn);
- } else {
- none.delete(hn);
- modified = true;
- }
- }
- for ( const hn of hnSet ) {
- const level = applyFilteringMode(filteringModes, hn, MODE_NONE);
- if ( level !== MODE_NONE ) { continue; }
- modified = true;
- }
- if ( modified === false ) { return; }
- return writeFilteringModeDetails(filteringModes);
-}
-
-/******************************************************************************/
-
export async function syncWithBrowserPermissions() {
const [
permissions,
diff --git a/platform/mv3/extension/js/mode-parser.js b/platform/mv3/extension/js/mode-parser.js
new file mode 100644
index 000000000..719a29efe
--- /dev/null
+++ b/platform/mv3/extension/js/mode-parser.js
@@ -0,0 +1,211 @@
+/*******************************************************************************
+
+ 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 { i18n$ } from './i18n.js';
+import punycode from './punycode.js';
+
+/******************************************************************************/
+
+function selectParser(scope, modes, node) {
+ const parser = perScopeParsers[scope.join('.')];
+ if ( parser === undefined ) { return false; }
+ return parser(scope, modes, node);
+}
+
+const validModes = [
+ 'none',
+ 'basic',
+ 'optimal',
+ 'complete',
+];
+
+const uglyModeNames = {
+ [i18n$('filteringMode0Name')]: 'none',
+ [i18n$('filteringMode1Name')]: 'basic',
+ [i18n$('filteringMode2Name')]: 'optimal',
+ [i18n$('filteringMode3Name')]: 'complete',
+};
+
+const prettyModeNames = {
+ none: i18n$('filteringMode0Name'),
+ basic: i18n$('filteringMode1Name'),
+ optimal: i18n$('filteringMode2Name'),
+ complete: i18n$('filteringMode3Name'),
+};
+
+const perScopeParsers = {
+ '': function(scope, modes, node) {
+ const { key, val } = node;
+ switch ( key ) {
+ case 'none':
+ case 'basic':
+ case 'optimal':
+ case 'complete':
+ case prettyModeNames.none:
+ case prettyModeNames.basic:
+ case prettyModeNames.optimal:
+ case prettyModeNames.complete: {
+ const mode = uglyModeNames[key] || key;
+ if ( val !== undefined ) { return false; }
+ modes[mode] ||= [];
+ scope.push(mode);
+ break;
+ }
+ default:
+ return false;
+ }
+ return true;
+ },
+ none: function(scope, modes, node) {
+ return addHostnameToMode(modes, 'none', node)
+ },
+ basic: function(scope, modes, node) {
+ return addHostnameToMode(modes, 'basic', node)
+ },
+ optimal: function(scope, modes, node) {
+ return addHostnameToMode(modes, 'optimal', node)
+ },
+ complete: function(scope, modes, node) {
+ return addHostnameToMode(modes, 'complete', node)
+ },
+};
+
+const addHostnameToMode = (modes, mode, node) => {
+ if ( node.list !== true ) { return false; }
+ modes[mode].push(punycode.toASCII(node.val));
+};
+
+/******************************************************************************/
+
+function depthFromIndent(line) {
+ const match = /^\s*/.exec(line);
+ const count = match[0].length;
+ if ( (count & 1) !== 0 ) { return -1; }
+ return count / 2;
+}
+
+/******************************************************************************/
+
+function nodeFromLine(line) {
+ const match = reNodeParser.exec(line);
+ const out = {};
+ if ( match === null ) { return out; }
+ if ( match[1] ) {
+ out.list = true;
+ }
+ if ( match[4] ) {
+ out.val = match[4].trim();
+ } else if ( match[3] ) {
+ out.key = match[2];
+ out.val = match[3].trim();
+ if ( out.val === "''" ) { out.val = '' };
+ } else {
+ out.key = match[2];
+ }
+ return out;
+}
+
+const reNodeParser = /^\s*(- )?(?:([^:]+):( \S.*)?|(\S.*))$/;
+
+/******************************************************************************/
+
+export function modesFromText(text, justbad = false) {
+ const lines = [ ...text.split(/\n\r|\r\n|\n|\r/) ];
+ const indices = [];
+ for ( let i = 0; i < lines.length; i++ ) {
+ const line = lines[i].trimEnd();
+ if ( line.trim().startsWith('#') ) { continue; }
+ indices.push(i);
+ }
+ // Discard leading empty lines
+ while ( indices.length !== 0 ) {
+ const s = lines[indices[0]].trim();
+ if ( s.length !== 0 ) { break; }
+ indices.shift();
+ }
+ // Discard trailing empty lines
+ while ( indices.length !== 0 ) {
+ const s = lines[indices.at(-1)].trim();
+ if ( s.length !== 0 ) { break; }
+ indices.pop();
+ }
+ // Parse
+ const modes = {};
+ const bad = [];
+ const scope = [];
+ for ( const i of indices ) {
+ const line = lines[i];
+ const depth = depthFromIndent(line);
+ if ( depth < 0 ) {
+ bad.push(i);
+ continue;
+ }
+ scope.length = depth;
+ const node = nodeFromLine(line);
+ const result = selectParser(scope, modes, node);
+ if ( result === false ) {
+ bad.push(i);
+ }
+ }
+ if ( justbad ) {
+ return bad.length !== 0 ? { bad } : { };
+ }
+ // Ensure all modes are present, and that one mode is the default one
+ const seen = new Map();
+ let defaultMode = '';
+ for ( const mode of validModes ) {
+ modes[mode] = new Set(modes[mode]);
+ if ( modes[mode].has('all-urls') ) {
+ defaultMode = mode;
+ }
+ for ( const hn of modes[mode] ) {
+ if ( seen.has(hn) ) {
+ modes[seen.get(hn)].delete(hn);
+ }
+ seen.set(hn, mode);
+ }
+ }
+ if ( defaultMode === '' ) {
+ defaultMode = 'optimal';
+ }
+ modes[defaultMode].clear();
+ modes[defaultMode].add('all-urls');
+ for ( const mode of validModes ) {
+ modes[mode] = Array.from(modes[mode]);
+ }
+ return { modes };
+}
+
+/******************************************************************************/
+
+export function textFromModes(modes) {
+ const out = [];
+ for ( const mode of validModes ) {
+ const hostnames = modes[mode];
+ if ( hostnames === undefined ) { continue; }
+ out.push(`${prettyModeNames[mode]}:`);
+ for ( const hn of hostnames ) {
+ out.push(` - ${punycode.toUnicode(hn)}`);
+ }
+ }
+ out.push('');
+ return out.join('\n');
+}
diff --git a/platform/mv3/extension/js/ro-dnr-editor.js b/platform/mv3/extension/js/ro-dnr-editor.js
new file mode 100644
index 000000000..c2e960bc7
--- /dev/null
+++ b/platform/mv3/extension/js/ro-dnr-editor.js
@@ -0,0 +1,95 @@
+/*******************************************************************************
+
+ 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 { DNREditor } from './dnr-editor.js';
+import { i18n$ } from './i18n.js';
+import { sendMessage } from './ext.js';
+import { textFromRules } from './dnr-parser.js';
+
+/******************************************************************************/
+
+export class ReadOnlyDNREditor extends DNREditor {
+ async getText(hint) {
+ if ( hint === 'dnr.ro.dynamic' ) {
+ const rules = await sendMessage({ what: 'getEffectiveDynamicRules' });
+ if ( Array.isArray(rules) === false ) { return; }
+ this.id = 'dynamic';
+ this.count = rules.length;
+ return textFromRules(rules, { keepId: true });
+ }
+ if ( hint === 'dnr.ro.session' ) {
+ const rules = await sendMessage({ what: 'getEffectiveSessionRules' });
+ if ( Array.isArray(rules) === false ) { return; }
+ this.id = 'session';
+ this.count = rules.length;
+ return textFromRules(rules, { keepId: true });
+ }
+ const match = /^dnr\.ro\.(.+)$/.exec(hint);
+ if ( match === null ) { return; }
+ this.id = match[1];
+ const allRulesetDetails = await sendMessage({ what: 'getRulesetDetails' });
+ const rulesetDetails = allRulesetDetails.find(a => a.id === this.id);
+ if ( rulesetDetails === undefined ) { return; }
+ const realms = {
+ plain: 'main',
+ regex: 'regex',
+ redirect: 'redirect',
+ modifyHeaders: 'modify-headers',
+ removeparam: 'removeparam',
+ };
+ const promises = [];
+ for ( const [ realm, dir ] of Object.entries(realms) ) {
+ if ( Boolean(rulesetDetails.rules?.[realm]) === false ) { continue; }
+ promises.push(
+ fetch(`./rulesets/${dir}/${this.id}.json`).then(response => response.json())
+ );
+ }
+ const parts = await Promise.all(promises);
+ const allRules = [];
+ for ( const rules of parts ) {
+ for ( const rule of rules ) {
+ allRules.push(rule);
+ }
+ }
+ this.count = allRules.length;
+ return textFromRules(allRules, { keepId: true });
+ }
+
+ on(editor) {
+ if ( typeof this.count !== 'number' ) {
+ return editor.updateSummaryPanel(null);
+ }
+ const template = document.querySelector('template.ro-summary-panel');
+ const fragment = template.content.cloneNode(true);
+ const root = fragment.querySelector('.summary-panel');
+ root.textContent = i18n$('dnrRulesCountInfo')
+ .replace('{count}', (this.count || 0).toLocaleString())
+ editor.updateSummaryPanel(root);
+ }
+
+ off(editor) {
+ editor.updateSummaryPanel(null);
+ }
+
+ exportToFile(text) {
+ return super.exportToFile(text, `${this.id}-dnr-ruleset.json`);
+ }
+};
diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js
index 16e041f34..d1d9eeb42 100644
--- a/platform/mv3/extension/js/ruleset-manager.js
+++ b/platform/mv3/extension/js/ruleset-manager.js
@@ -306,19 +306,30 @@ async function updateDynamicRules() {
if ( dynamicRegexCount !== 0 ) {
ubolLog(`Using ${dynamicRegexCount}/${dnr.MAX_NUMBER_OF_REGEX_RULES} dynamic regex-based DNR rules`);
}
- return Promise.all([
- dnr.updateDynamicRules({ addRules, removeRuleIds }).then(( ) => {
- if ( removeRuleIds.length !== 0 ) {
- ubolLog(`Remove ${removeRuleIds.length} dynamic DNR rules`);
- }
- if ( addRules.length !== 0 ) {
- ubolLog(`Add ${addRules.length} dynamic DNR rules`);
- }
- }).catch(reason => {
- console.error(`updateDynamicRules() / ${reason}`);
- }),
- updateSessionRules(),
- ]);
+ try {
+ await dnr.updateDynamicRules({ addRules, removeRuleIds });
+ if ( removeRuleIds.length !== 0 ) {
+ ubolLog(`Remove ${removeRuleIds.length} dynamic DNR rules`);
+ }
+ if ( addRules.length !== 0 ) {
+ ubolLog(`Add ${addRules.length} dynamic DNR rules`);
+ }
+ } catch(reason) {
+ console.error(`updateDynamicRules() / ${reason}`);
+ }
+ await updateSessionRules();
+}
+
+/******************************************************************************/
+
+async function getEffectiveDynamicRules() {
+ const allRules = await dnr.getDynamicRules();
+ const dynamicRules = [];
+ for ( const rule of allRules ) {
+ if ( rule.id >= USER_RULES_BASE_RULE_ID ) { continue; }
+ dynamicRules.push(rule);
+ }
+ return dynamicRules;
}
/******************************************************************************/
@@ -441,16 +452,29 @@ async function updateSessionRules() {
if ( sessionRegexCount !== 0 ) {
ubolLog(`Using ${sessionRegexCount}/${dnr.MAX_NUMBER_OF_REGEX_RULES} session regex-based DNR rules`);
}
- return dnr.updateSessionRules({ addRules, removeRuleIds }).then(( ) => {
+ try {
+ await dnr.updateSessionRules({ addRules, removeRuleIds });
if ( removeRuleIds.length !== 0 ) {
ubolLog(`Remove ${removeRuleIds.length} session DNR rules`);
}
if ( addRules.length !== 0 ) {
ubolLog(`Add ${addRules.length} session DNR rules`);
}
- }).catch(reason => {
+ } catch(reason) {
console.error(`updateSessionRules() / ${reason}`);
- });
+ }
+}
+
+/******************************************************************************/
+
+async function getEffectiveSessionRules() {
+ const allRules = await dnr.getSessionRules();
+ const sessionRules = [];
+ for ( const rule of allRules ) {
+ if ( rule.id >= USER_RULES_BASE_RULE_ID ) { continue; }
+ sessionRules.push(rule);
+ }
+ return sessionRules;
}
/******************************************************************************/
@@ -751,6 +775,8 @@ export {
enableRulesets,
excludeFromStrictBlock,
filteringModesToDNR,
+ getEffectiveDynamicRules,
+ getEffectiveSessionRules,
getEffectiveUserRules,
getEnabledRulesetsDetails,
getRulesetDetails,
diff --git a/platform/mv3/extension/js/rw-dnr-editor.js b/platform/mv3/extension/js/rw-dnr-editor.js
new file mode 100644
index 000000000..a1e317e02
--- /dev/null
+++ b/platform/mv3/extension/js/rw-dnr-editor.js
@@ -0,0 +1,401 @@
+/*******************************************************************************
+
+ 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 { 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
+ if ( lineFrom.number !== 1 ) {
+ const lineBefore = newDoc.line(lineFrom.number-1);
+ if ( /^---\s*$/.test(lineBefore.text) === false ) { return; }
+ }
+ const pastedText = newDoc.sliceString(from, to);
+ const rules = this.rulesFromJSON(pastedText);
+ if ( rules === undefined ) { return; }
+ const yamlText = textFromRules(rules);
+ if ( yamlText === undefined ) { return; }
+ editor.view.dispatch({ changes: { from, to, insert: yamlText } });
+ self.cm6.foldAll(editor.view);
+ return true;
+ }
+
+ sequenceScopes = [
+ 'action:redirect:transform:queryTransform:addOrReplaceParams:',
+ 'action:redirect:transform:queryTransform:removeParams:',
+ 'condition:resourceTypes:',
+ 'condition:excludedResourceTypes:',
+ 'condition:initiatorDomains:',
+ 'condition:excludedInitiatorDomains:',
+ 'condition:requestDomains:',
+ 'condition:excludedRequestDomains:',
+ 'condition:requestMethods:',
+ 'condition:excludedRequestMethods:',
+ 'condition:responseHeaders:',
+ 'condition:excludedResponseHeaders:',
+ ];
+
+ ioAccept = '.json,application/json';
+};
diff --git a/platform/mv3/extension/js/settings.js b/platform/mv3/extension/js/settings.js
index 10815a9fb..254e321d7 100644
--- a/platform/mv3/extension/js/settings.js
+++ b/platform/mv3/extension/js/settings.js
@@ -22,23 +22,10 @@
import { browser, sendMessage } from './ext.js';
import { dom, qs$ } from './dom.js';
import { hashFromIterable } from './dashboard.js';
-import { i18n$ } from './i18n.js';
-import punycode from './punycode.js';
import { renderFilterLists } from './filter-lists.js';
/******************************************************************************/
-const cm6 = self.cm6;
-
-const cmTrustedSites = (( ) => {
- const options = {};
- if ( dom.cl.has(':root', 'dark') ) {
- options.oneDark = true;
- }
- options.placeholder = i18n$('noFilteringModePlaceholder');
- return cm6.createEditorView(options, qs$('#trustedSites'));
-})();
-
let cachedRulesetData = {};
/******************************************************************************/
@@ -60,7 +47,6 @@ function renderWidgets() {
}
renderDefaultMode();
- renderTrustedSites();
qs$('#autoReload input[type="checkbox"]').checked = cachedRulesetData.autoReload;
@@ -175,48 +161,6 @@ dom.on('#developerMode input[type="checkbox"]', 'change', ev => {
/******************************************************************************/
-function renderTrustedSites() {
- const hostnames = cachedRulesetData.trustedSites || [];
- let text = hostnames.map(hn => punycode.toUnicode(hn)).join('\n');
- if ( text !== '' ) { text += '\n'; }
- cmTrustedSites.dispatch({
- changes: {
- from: 0, to: cmTrustedSites.state.doc.length,
- insert: text
- },
- });
-}
-
-function changeTrustedSites() {
- const hostnames = getStagedTrustedSites();
- const hash = hashFromIterable(cachedRulesetData.trustedSites || []);
- if ( hashFromIterable(hostnames) === hash ) { return; }
- sendMessage({
- what: 'setTrustedSites',
- hostnames,
- });
-}
-
-function getStagedTrustedSites() {
- const text = cmTrustedSites.state.doc.toString();
- return text.split(/\s/).map(hn => {
- if ( hn === '' ) { return ''; }
- try {
- return punycode.toASCII(
- (new URL(`https://${hn}/`)).hostname
- );
- } catch {
- }
- return '';
- }).filter(hn => hn !== '');
-}
-
-dom.on(cmTrustedSites.contentDOM, 'blur', changeTrustedSites);
-
-self.addEventListener('beforeunload', changeTrustedSites);
-
-/******************************************************************************/
-
function listen() {
const bc = new self.BroadcastChannel('uBOL');
bc.onmessage = listen.onmessage;
@@ -228,21 +172,6 @@ listen.onmessage = ev => {
const local = cachedRulesetData;
let render = false;
- // Keep added sites which have not yet been committed
- if ( message.trustedSites !== undefined ) {
- if ( hashFromIterable(message.trustedSites) !== hashFromIterable(local.trustedSites) ) {
- const current = new Set(local.trustedSites);
- const staged = new Set(getStagedTrustedSites());
- for ( const hn of staged ) {
- if ( current.has(hn) === false ) { continue; }
- staged.delete(hn);
- }
- const combined = Array.from(new Set([ ...message.trustedSites, ...staged ]));
- local.trustedSites = combined;
- render = true;
- }
- }
-
if ( message.hasOmnipotence !== undefined ) {
if ( message.hasOmnipotence !== local.hasOmnipotence ) {
local.hasOmnipotence = message.hasOmnipotence;
diff --git a/platform/mv3/extension/lib/codemirror/codemirror-ubol b/platform/mv3/extension/lib/codemirror/codemirror-ubol
index 4b9666da6..4352f5727 160000
--- a/platform/mv3/extension/lib/codemirror/codemirror-ubol
+++ b/platform/mv3/extension/lib/codemirror/codemirror-ubol
@@ -1 +1 @@
-Subproject commit 4b9666da6d3cf0321493c6a267b11cee88677411
+Subproject commit 4352f572758bc43ebabddb67b98875e8fde7c297
diff --git a/src/js/i18n.js b/src/js/i18n.js
index 2f6f7dc01..6232fdd46 100644
--- a/src/js/i18n.js
+++ b/src/js/i18n.js
@@ -263,6 +263,11 @@ if ( isBackgroundProcess !== true ) {
elem.setAttribute('aria-label', text);
}
}
+
+ for ( const elem of root.querySelectorAll('[data-i18n-label]') ) {
+ const text = i18n$(elem.getAttribute('data-i18n-label'));
+ elem.setAttribute('label', text);
+ }
};
i18n.renderElapsedTimeToString = function(tstamp) {