From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/mailnews/search/content/CustomHeaders.js | 194 +++ comm/mailnews/search/content/CustomHeaders.xhtml | 61 + comm/mailnews/search/content/FilterEditor.js | 809 ++++++++++ comm/mailnews/search/content/FilterEditor.xhtml | 136 ++ comm/mailnews/search/content/searchTerm.inc.xhtml | 27 + comm/mailnews/search/content/searchTerm.js | 568 +++++++ comm/mailnews/search/content/searchWidgets.js | 1779 +++++++++++++++++++++ comm/mailnews/search/content/viewLog.js | 38 + comm/mailnews/search/content/viewLog.xhtml | 65 + 9 files changed, 3677 insertions(+) create mode 100644 comm/mailnews/search/content/CustomHeaders.js create mode 100644 comm/mailnews/search/content/CustomHeaders.xhtml create mode 100644 comm/mailnews/search/content/FilterEditor.js create mode 100644 comm/mailnews/search/content/FilterEditor.xhtml create mode 100644 comm/mailnews/search/content/searchTerm.inc.xhtml create mode 100644 comm/mailnews/search/content/searchTerm.js create mode 100644 comm/mailnews/search/content/searchWidgets.js create mode 100644 comm/mailnews/search/content/viewLog.js create mode 100644 comm/mailnews/search/content/viewLog.xhtml (limited to 'comm/mailnews/search/content') diff --git a/comm/mailnews/search/content/CustomHeaders.js b/comm/mailnews/search/content/CustomHeaders.js new file mode 100644 index 0000000000..4bfc4a3b78 --- /dev/null +++ b/comm/mailnews/search/content/CustomHeaders.js @@ -0,0 +1,194 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var gAddButton; +var gRemoveButton; +var gHeaderInputElement; +var gArrayHdrs; +var gHdrsList; +var gContainer; +var gFilterBundle = null; +var gCustomBundle = null; + +window.addEventListener("DOMContentLoaded", onLoad); +document.addEventListener("dialogaccept", onOk); +document.addEventListener("dialogextra1", onAddHeader); +document.addEventListener("dialogextra2", onRemoveHeader); + +function onLoad() { + let hdrs = Services.prefs.getCharPref("mailnews.customHeaders"); + gHeaderInputElement = document.getElementById("headerInput"); + gHeaderInputElement.focus(); + + gHdrsList = document.getElementById("headerList"); + gArrayHdrs = []; + gAddButton = document.getElementById("addButton"); + gRemoveButton = document.getElementById("removeButton"); + + initializeDialog(hdrs); + updateAddButton(true); + updateRemoveButton(); +} + +function initializeDialog(hdrs) { + if (hdrs) { + hdrs = hdrs.replace(/\s+/g, ""); // remove white spaces before splitting + gArrayHdrs = hdrs.split(":"); + for (var i = 0; i < gArrayHdrs.length; i++) { + if (!gArrayHdrs[i]) { + // Remove any null elements. + gArrayHdrs.splice(i, 1); + } + } + initializeRows(); + } +} + +function initializeRows() { + for (var i = 0; i < gArrayHdrs.length; i++) { + addRow(TrimString(gArrayHdrs[i])); + } +} + +function onTextInput() { + // enable the add button if the user has started to type text + updateAddButton(gHeaderInputElement.value == ""); +} + +function onOk() { + if (gArrayHdrs.length) { + var hdrs; + if (gArrayHdrs.length == 1) { + hdrs = gArrayHdrs; + } else { + hdrs = gArrayHdrs.join(": "); + } + Services.prefs.setCharPref("mailnews.customHeaders", hdrs); + // flush prefs to disk, in case we crash, to avoid dataloss and problems with filters that use the custom headers + Services.prefs.savePrefFile(null); + } else { + Services.prefs.clearUserPref("mailnews.customHeaders"); // clear the pref, no custom headers + } + + window.arguments[0].selectedVal = gHdrsList.selectedItem + ? gHdrsList.selectedItem.label + : null; +} + +function customHeaderOverflow() { + var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib; + if ( + gArrayHdrs.length >= + nsMsgSearchAttrib.kNumMsgSearchAttributes - + nsMsgSearchAttrib.OtherHeader - + 1 + ) { + if (!gFilterBundle) { + gFilterBundle = document.getElementById("bundle_filter"); + } + + var alertText = gFilterBundle.getString("customHeaderOverflow"); + Services.prompt.alert(window, null, alertText); + return true; + } + return false; +} + +function onAddHeader() { + var newHdr = TrimString(gHeaderInputElement.value); + + if (!isRFC2822Header(newHdr)) { + // if user entered an invalid rfc822 header field name, bail out. + if (!gCustomBundle) { + gCustomBundle = document.getElementById("bundle_custom"); + } + + var alertText = gCustomBundle.getString("colonInHeaderName"); + Services.prompt.alert(window, null, alertText); + return; + } + + gHeaderInputElement.value = ""; + if (!newHdr || customHeaderOverflow()) { + return; + } + if (!duplicateHdrExists(newHdr)) { + gArrayHdrs[gArrayHdrs.length] = newHdr; + var newItem = addRow(newHdr); + gHdrsList.selectItem(newItem); // make sure the new entry is selected in the tree + // now disable the add button + updateAddButton(true); + gHeaderInputElement.focus(); // refocus the input field for the next custom header + } +} + +function isRFC2822Header(hdr) { + var charCode; + for (var i = 0; i < hdr.length; i++) { + charCode = hdr.charCodeAt(i); + // 58 is for colon and 33 and 126 are us-ascii bounds that should be used for header field name, as per rfc2822 + + if (charCode < 33 || charCode == 58 || charCode > 126) { + return false; + } + } + return true; +} + +function duplicateHdrExists(hdr) { + for (var i = 0; i < gArrayHdrs.length; i++) { + if (gArrayHdrs[i] == hdr) { + return true; + } + } + return false; +} + +function onRemoveHeader() { + var listitem = gHdrsList.selectedItems[0]; + if (!listitem) { + return; + } + listitem.remove(); + var selectedHdr = listitem.firstElementChild.getAttribute("value").trim(); + for (let i = 0; i < gArrayHdrs.length; i++) { + if (gArrayHdrs[i] == selectedHdr) { + gArrayHdrs.splice(i, 1); + break; + } + } +} + +function addRow(newHdr) { + return gHdrsList.appendItem(newHdr, ""); +} + +function updateAddButton(aDisable) { + // only update the button if the disabled state changed + if (aDisable == gAddButton.disabled) { + return; + } + + gAddButton.disabled = aDisable; + document.querySelector("dialog").defaultButton = aDisable + ? "accept" + : "extra1"; +} + +function updateRemoveButton() { + var headerSelected = gHdrsList.selectedItems.length > 0; + gRemoveButton.disabled = !headerSelected; + if (gRemoveButton.disabled) { + gHeaderInputElement.focus(); + } +} + +// Remove whitespace from both ends of a string +function TrimString(string) { + if (!string) { + return ""; + } + return string.trim(); +} diff --git a/comm/mailnews/search/content/CustomHeaders.xhtml b/comm/mailnews/search/content/CustomHeaders.xhtml new file mode 100644 index 0000000000..9a2ac8468f --- /dev/null +++ b/comm/mailnews/search/content/CustomHeaders.xhtml @@ -0,0 +1,61 @@ + + +#ifdef MOZ_THUNDERBIRD + +#else + +#endif + + + + + + &window.title; + + + + + + + + + + + + + + + + + diff --git a/comm/mailnews/search/content/FilterEditor.js b/comm/mailnews/search/content/FilterEditor.js new file mode 100644 index 0000000000..3b93773192 --- /dev/null +++ b/comm/mailnews/search/content/FilterEditor.js @@ -0,0 +1,809 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from searchTerm.js */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); + +// The actual filter that we're editing if it is a _saved_ filter or prefill; +// void otherwise. +var gFilter; +// cache the key elements we need +var gFilterList; +// The filter name as it appears in the "Filter Name" field of dialog. +var gFilterNameElement; +var gFilterTypeSelector; +var gFilterBundle; +var gPreFillName; +var gFilterActionList; +var gCustomActions = null; +var gFilterType; +var gFilterPosition = 0; + +var gFilterActionStrings = [ + "none", + "movemessage", + "setpriorityto", + "deletemessage", + "markasread", + "ignorethread", + "watchthread", + "markasflagged", + "label", + "replytomessage", + "forwardmessage", + "stopexecution", + "deletefrompopserver", + "leaveonpopserver", + "setjunkscore", + "fetchfrompopserver", + "copymessage", + "addtagtomessage", + "ignoresubthread", + "markasunread", +]; + +// A temporary filter with the current state of actions in the UI. +var gTempFilter = null; +// nsIMsgRuleAction[] - the currently defined actions in the order they will be run. +var gActionListOrdered = null; + +var gFilterEditorMsgWindow = null; + +window.addEventListener("DOMContentLoaded", filterEditorOnLoad, { once: true }); +document.addEventListener("dialogaccept", onAccept); + +function filterEditorOnLoad() { + getCustomActions(); + initializeSearchWidgets(); + initializeFilterWidgets(); + + gFilterBundle = document.getElementById("bundle_filter"); + + if ("arguments" in window && window.arguments[0]) { + var args = window.arguments[0]; + + if ("filterList" in args) { + gFilterList = args.filterList; + // the postPlugin filters cannot be applied to servers that are + // deferred, (you must define them on the deferredTo server instead). + let server = gFilterList.folder.server; + if (server.rootFolder != server.rootMsgFolder) { + gFilterTypeSelector.disableDeferredAccount(); + } + } + + if ("filterPosition" in args) { + gFilterPosition = args.filterPosition; + } + + if ("filter" in args) { + // editing a filter + gFilter = window.arguments[0].filter; + initializeDialog(gFilter); + } else { + if (gFilterList) { + setSearchScope(getScopeFromFilterList(gFilterList)); + } + // if doing prefill filter create a new filter and populate it. + if ("filterName" in args) { + gPreFillName = args.filterName; + + // Passing null as the parameter to createFilter to keep the name empty + // until later where we assign the name. + gFilter = gFilterList.createFilter(null); + + var term = gFilter.createTerm(); + + term.attrib = Ci.nsMsgSearchAttrib.Default; + if ("fieldName" in args && args.fieldName) { + // fieldName should contain the name of the field in which to search, + // from nsMsgSearchTerm.cpp::SearchAttribEntryTable, e.g. "to" or "cc" + try { + term.attrib = term.getAttributeFromString(args.fieldName); + } catch (e) { + /* Invalid string is fine, just ignore it. */ + } + } + if (term.attrib == Ci.nsMsgSearchAttrib.Default) { + term.attrib = Ci.nsMsgSearchAttrib.Sender; + } + + term.op = Ci.nsMsgSearchOp.Is; + term.booleanAnd = gSearchBooleanRadiogroup.value == "and"; + + var termValue = term.value; + termValue.attrib = term.attrib; + termValue.str = gPreFillName; + + term.value = termValue; + + gFilter.appendTerm(term); + + // the default action for news filters is Delete + // for everything else, it's MoveToFolder + var filterAction = gFilter.createAction(); + filterAction.type = + getScopeFromFilterList(gFilterList) == Ci.nsMsgSearchScope.newsFilter + ? Ci.nsMsgFilterAction.Delete + : Ci.nsMsgFilterAction.MoveToFolder; + gFilter.appendAction(filterAction); + initializeDialog(gFilter); + } else if ("copiedFilter" in args) { + // we are copying a filter + let copiedFilter = args.copiedFilter; + let copiedName = gFilterBundle.getFormattedString( + "copyToNewFilterName", + [copiedFilter.filterName] + ); + let newFilter = gFilterList.createFilter(copiedName); + + // copy the actions + for (let i = 0; i < copiedFilter.actionCount; i++) { + let filterAction = copiedFilter.getActionAt(i); + newFilter.appendAction(filterAction); + } + + // copy the search terms + for (let searchTerm of copiedFilter.searchTerms) { + let newTerm = newFilter.createTerm(); + newTerm.attrib = searchTerm.attrib; + newTerm.op = searchTerm.op; + newTerm.booleanAnd = searchTerm.booleanAnd; + newTerm.value = searchTerm.value; + newFilter.appendTerm(newTerm); + } + + newFilter.filterType = copiedFilter.filterType; + + gPreFillName = copiedName; + gFilter = newFilter; + + initializeDialog(gFilter); + + // We reset the filter name, because otherwise the saveFilter() + // function thinks we are editing a filter, and will thus skip the name + // uniqueness check. + gFilter.filterName = ""; + } else { + // fake the first more button press + onMore(null); + } + } + } + + if (!gFilter) { + // This is a new filter. Set to both Incoming and Manual contexts. + gFilterTypeSelector.setType( + Ci.nsMsgFilterType.Incoming | Ci.nsMsgFilterType.Manual + ); + } + + // in the case of a new filter, we may not have an action row yet. + ensureActionRow(); + gFilterType = gFilterTypeSelector.getType(); + + gFilterNameElement.select(); + // This call is required on mac and linux. It has no effect under win32. See bug 94800. + gFilterNameElement.focus(); +} + +function onEnterInSearchTerm(event) { + if (event.ctrlKey || (Services.appinfo.OS == "Darwin" && event.metaKey)) { + // If accel key (Ctrl on Win/Linux, Cmd on Mac) was held too, accept the dialog. + document.querySelector("dialog").acceptDialog(); + } else { + // If only plain Enter was pressed, add a new rule line. + onMore(event); + } +} + +function onAccept(event) { + try { + if (!saveFilter()) { + event.preventDefault(); + return; + } + } catch (e) { + console.error(e); + event.preventDefault(); + return; + } + + // parent should refresh filter list.. + // this should REALLY only happen when some criteria changes that + // are displayed in the filter dialog, like the filter name + window.arguments[0].refresh = true; + window.arguments[0].newFilter = gFilter; +} + +function duplicateFilterNameExists(filterName) { + if (gFilterList) { + for (var i = 0; i < gFilterList.filterCount; i++) { + if (filterName == gFilterList.getFilterAt(i).filterName) { + return true; + } + } + } + return false; +} + +function getScopeFromFilterList(filterList) { + if (!filterList) { + dump("yikes, null filterList\n"); + return Ci.nsMsgSearchScope.offlineMail; + } + return filterList.folder.server.filterScope; +} + +function getScope(filter) { + return getScopeFromFilterList(filter.filterList); +} + +function initializeFilterWidgets() { + gFilterNameElement = document.getElementById("filterName"); + gFilterActionList = document.getElementById("filterActionList"); + initializeFilterTypeSelector(); +} + +function initializeFilterTypeSelector() { + /** + * This object controls code interaction with the widget allowing specifying + * the filter type (event when the filter is run). + */ + gFilterTypeSelector = { + checkBoxManual: document.getElementById("runManual"), + checkBoxIncoming: document.getElementById("runIncoming"), + + menulistIncoming: document.getElementById("pluginsRunOrder"), + + menuitemBeforePlugins: document.getElementById("runBeforePlugins"), + menuitemAfterPlugins: document.getElementById("runAfterPlugins"), + + checkBoxArchive: document.getElementById("runArchive"), + checkBoxOutgoing: document.getElementById("runOutgoing"), + checkBoxPeriodic: document.getElementById("runPeriodic"), + + /** + * Returns the currently set filter type (checkboxes) in terms + * of a Ci.Ci.nsMsgFilterType value. + */ + getType() { + let type = Ci.nsMsgFilterType.None; + + if (this.checkBoxManual.checked) { + type |= Ci.nsMsgFilterType.Manual; + } + + if (this.checkBoxIncoming.checked) { + if (this.menulistIncoming.selectedItem == this.menuitemAfterPlugins) { + type |= Ci.nsMsgFilterType.PostPlugin; + } else if ( + getScopeFromFilterList(gFilterList) == Ci.nsMsgSearchScope.newsFilter + ) { + type |= Ci.nsMsgFilterType.NewsRule; + } else { + type |= Ci.nsMsgFilterType.InboxRule; + } + } + + if (this.checkBoxArchive.checked) { + type |= Ci.nsMsgFilterType.Archive; + } + + if (this.checkBoxOutgoing.checked) { + type |= Ci.nsMsgFilterType.PostOutgoing; + } + + if (this.checkBoxPeriodic.checked) { + type |= Ci.nsMsgFilterType.Periodic; + } + + return type; + }, + + /** + * Sets the checkboxes to represent the filter type passed in. + * + * @param aType the filter type to set in terms + * of Ci.Ci.nsMsgFilterType values. + */ + setType(aType) { + // If there is no type (event) requested, force "when manually run" + if (aType == Ci.nsMsgFilterType.None) { + aType = Ci.nsMsgFilterType.Manual; + } + + this.checkBoxManual.checked = aType & Ci.nsMsgFilterType.Manual; + + this.checkBoxIncoming.checked = + aType & (Ci.nsMsgFilterType.PostPlugin | Ci.nsMsgFilterType.Incoming); + + this.menulistIncoming.selectedItem = + aType & Ci.nsMsgFilterType.PostPlugin + ? this.menuitemAfterPlugins + : this.menuitemBeforePlugins; + + this.checkBoxArchive.checked = aType & Ci.nsMsgFilterType.Archive; + + this.checkBoxOutgoing.checked = aType & Ci.nsMsgFilterType.PostOutgoing; + + this.checkBoxPeriodic.checked = aType & Ci.nsMsgFilterType.Periodic; + const periodMinutes = gFilterList.folder.server.getIntValue( + "periodicFilterRateMinutes" + ); + document.getElementById("runPeriodic").label = PluralForm.get( + periodMinutes, + gFilterBundle.getString("contextPeriodic.label") + ).replace("#1", periodMinutes); + + this.updateClassificationMenu(); + }, + + /** + * Enable the "before/after classification" menulist depending on + * whether "run when incoming mail" is selected. + */ + updateClassificationMenu() { + this.menulistIncoming.disabled = !this.checkBoxIncoming.checked; + updateFilterType(); + }, + + /** + * Disable the options unsuitable for deferred accounts. + */ + disableDeferredAccount() { + this.menuitemAfterPlugins.disabled = true; + this.checkBoxOutgoing.disabled = true; + }, + }; +} + +function initializeDialog(filter) { + gFilterNameElement.value = filter.filterName; + gFilterTypeSelector.setType(filter.filterType); + + let numActions = filter.actionCount; + for (let actionIndex = 0; actionIndex < numActions; actionIndex++) { + let filterAction = filter.getActionAt(actionIndex); + + let newActionRow = document.createXULElement("richlistitem", { + is: "ruleaction-richlistitem", + }); + newActionRow.setAttribute("initialActionIndex", actionIndex); + newActionRow.className = "ruleaction"; + gFilterActionList.appendChild(newActionRow); + newActionRow.setAttribute( + "value", + filterAction.type == Ci.nsMsgFilterAction.Custom + ? filterAction.customId + : gFilterActionStrings[filterAction.type] + ); + newActionRow.setAttribute("onfocus", "this.storeFocus();"); + } + + var gSearchScope = getFilterScope( + getScope(filter), + filter.filterType, + filter.filterList + ); + initializeSearchRows(gSearchScope, filter.searchTerms); + setFilterScope(filter.filterType, filter.filterList); +} + +function ensureActionRow() { + // make sure we have at least one action row visible to the user + if (!gFilterActionList.getRowCount()) { + let newActionRow = document.createXULElement("richlistitem", { + is: "ruleaction-richlistitem", + }); + newActionRow.className = "ruleaction"; + gFilterActionList.appendChild(newActionRow); + newActionRow.mRemoveButton.disabled = true; + } +} + +// move to overlay +function saveFilter() { + // See if at least one filter type (activation event) is selected. + if (gFilterType == Ci.nsMsgFilterType.None) { + Services.prompt.alert( + window, + gFilterBundle.getString("mustHaveFilterTypeTitle"), + gFilterBundle.getString("mustHaveFilterTypeMessage") + ); + return false; + } + + let filterName = gFilterNameElement.value; + // If we think have a duplicate, then we need to check that if we + // have an original filter name (i.e. we are editing a filter), then + // we must check that the original is not the current as that is what + // the duplicateFilterNameExists function will have picked up. + if ( + (!gFilter || gFilter.filterName != filterName) && + duplicateFilterNameExists(filterName) + ) { + Services.prompt.alert( + window, + gFilterBundle.getString("cannotHaveDuplicateFilterTitle"), + gFilterBundle.getString("cannotHaveDuplicateFilterMessage") + ); + return false; + } + + // Check that all of the search attributes and operators are valid. + function rule_desc(index, obj) { + return ( + index + + 1 + + " (" + + obj.searchattribute.label + + ", " + + obj.searchoperator.label + + ")" + ); + } + + let invalidRule = false; + for (let index = 0; index < gSearchTerms.length; index++) { + let obj = gSearchTerms[index].obj; + // We don't need to check validity of matchAll terms + if (obj.matchAll) { + continue; + } + + // the term might be an offscreen one that we haven't initialized yet + let searchTerm = obj.searchTerm; + if (!searchTerm && !gSearchTerms[index].initialized) { + continue; + } + + if (isNaN(obj.searchattribute.value)) { + // is this a custom term? + let customTerm = MailServices.filters.getCustomTerm( + obj.searchattribute.value + ); + if (!customTerm) { + invalidRule = true; + console.error( + "Filter not saved because custom search term '" + + obj.searchattribute.value + + "' in rule " + + rule_desc(index, obj) + + " not found" + ); + } else if ( + !customTerm.getAvailable(obj.searchScope, obj.searchattribute.value) + ) { + invalidRule = true; + console.error( + "Filter not saved because custom search term '" + + customTerm.name + + "' in rule " + + rule_desc(index, obj) + + " not available" + ); + } + } else { + let otherHeader = Ci.nsMsgSearchAttrib.OtherHeader; + let attribValue = + obj.searchattribute.value > otherHeader + ? otherHeader + : obj.searchattribute.value; + if ( + !obj.searchattribute.validityTable.getAvailable( + attribValue, + obj.searchoperator.value + ) + ) { + invalidRule = true; + console.error( + "Filter not saved because standard search term '" + + attribValue + + "' in rule " + + rule_desc(index, obj) + + " not available in this context" + ); + } + } + + if (invalidRule) { + Services.prompt.alert( + window, + gFilterBundle.getString("searchTermsInvalidTitle"), + gFilterBundle.getFormattedString("searchTermsInvalidRule", [ + obj.searchattribute.label, + obj.searchoperator.label, + ]) + ); + return false; + } + } + + // before we go any further, validate each specified filter action, abort the save + // if any of the actions is invalid... + for (let index = 0; index < gFilterActionList.itemCount; index++) { + var listItem = gFilterActionList.getItemAtIndex(index); + if (!listItem.validateAction()) { + return false; + } + } + + // if we made it here, all of the actions are valid, so go ahead and save the filter + let isNewFilter; + if (!gFilter) { + // This is a new filter + gFilter = gFilterList.createFilter(filterName); + isNewFilter = true; + gFilter.enabled = true; + } else { + // We are working with an existing filter object, + // either editing or using prefill + gFilter.filterName = filterName; + // Prefilter is treated as a new filter. + if (gPreFillName) { + isNewFilter = true; + gFilter.enabled = true; + } else { + isNewFilter = false; + } + + gFilter.clearActionList(); + } + + // add each filteraction to the filter + for (let index = 0; index < gFilterActionList.itemCount; index++) { + gFilterActionList.getItemAtIndex(index).saveToFilter(gFilter); + } + + // If we do not have a filter name at this point, generate one. + if (!gFilter.filterName) { + AssignMeaningfulName(); + } + + gFilter.filterType = gFilterType; + gFilter.searchTerms = saveSearchTerms(gFilter.searchTerms, gFilter); + + if (isNewFilter) { + // new filter - insert into gFilterList + gFilterList.insertFilterAt(gFilterPosition, gFilter); + } + + // success! + return true; +} + +/** + * Check if the list of actions the user created will be executed in a different order. + * Exposes a note to the user if that is the case. + */ +function checkActionsReorder() { + setTimeout(_checkActionsReorder, 0); +} + +/** + * This should be called from setTimeout otherwise some of the elements calling + * may not be fully initialized yet (e.g. we get ".saveToFilter is not a function"). + * It is OK to schedule multiple timeouts with this function. + */ +function _checkActionsReorder() { + // Create a temporary disposable filter and add current actions to it. + if (!gTempFilter) { + gTempFilter = gFilterList.createFilter(""); + } else { + gTempFilter.clearActionList(); + } + + for (let index = 0; index < gFilterActionList.itemCount; index++) { + gFilterActionList.getItemAtIndex(index).saveToFilter(gTempFilter); + } + + // Now get the actions out of the filter in the order they will be executed in. + gActionListOrdered = gTempFilter.sortedActionList; + + // Compare the two lists. + let statusBar = document.getElementById("statusbar"); + for (let index = 0; index < gActionListOrdered.length; index++) { + if (index != gTempFilter.getActionIndex(gActionListOrdered[index])) { + // If the lists are not the same unhide the status bar and show warning. + statusBar.style.visibility = "visible"; + return; + } + } + + statusBar.style.visibility = "hidden"; +} + +/** + * Show a dialog with the ordered list of actions. + * The fetching of action label and argument is separated from checkActionsReorder + * function to make that one more lightweight. The list is built only upon + * user request. + */ +function showActionsOrder() { + // Fetch the actions and arguments as a string. + let actionStrings = []; + for (let i = 0; i < gFilterActionList.itemCount; i++) { + let ruleAction = gFilterActionList.getItemAtIndex(i); + let actionTarget = ruleAction.children[1]; + let actionItem = actionTarget.ruleactiontargetElement; + let actionItemLabel = actionItem && actionItem.children[0].label; + + let actionString = { + label: ruleAction.mRuleActionType.label, + argument: "", + }; + if (actionItem) { + if (actionItemLabel) { + actionString.argument = actionItemLabel; + } else { + actionString.argument = actionItem.children[0].value; + } + } + actionStrings.push(actionString); + } + + // Present a nicely formatted list of action names and arguments. + let actionList = gFilterBundle.getString("filterActionOrderExplanation"); + for (let i = 0; i < gActionListOrdered.length; i++) { + let actionIndex = gTempFilter.getActionIndex(gActionListOrdered[i]); + let action = actionStrings[actionIndex]; + actionList += gFilterBundle.getFormattedString("filterActionItem", [ + i + 1, + action.label, + action.argument, + ]); + } + + Services.prompt.confirmEx( + window, + gFilterBundle.getString("filterActionOrderTitle"), + actionList, + Services.prompt.BUTTON_TITLE_OK, + null, + null, + null, + null, + { value: false } + ); +} + +function AssignMeaningfulName() { + // termRoot points to the first search object, which is the one we care about. + let termRoot = gSearchTerms[0].obj; + // stub is used as the base name for a filter. + let stub; + + // If this is a Match All Messages Filter, we already know the name to assign. + if (termRoot.matchAll) { + stub = gFilterBundle.getString("matchAllFilterName"); + } else { + // Assign a name based on the first search term. + let term = termRoot.searchattribute.label; + let operator = termRoot.searchoperator.label; + let value = termRoot.searchvalue.getReadableValue(); + stub = gFilterBundle.getFormattedString("filterAutoNameStr", [ + term, + operator, + value, + ]); + } + + // Whatever name we have used, 'uniquify' it. + let tempName = stub; + let count = 1; + while (duplicateFilterNameExists(tempName)) { + count++; + tempName = `${stub} ${count}`; + } + gFilter.filterName = tempName; +} + +function UpdateAfterCustomHeaderChange() { + updateSearchAttributes(); +} + +// if you use msgWindow, please make sure that destructor gets called when you close the "window" +function GetFilterEditorMsgWindow() { + if (!gFilterEditorMsgWindow) { + var msgWindowContractID = "@mozilla.org/messenger/msgwindow;1"; + var nsIMsgWindow = Ci.nsIMsgWindow; + gFilterEditorMsgWindow = + Cc[msgWindowContractID].createInstance(nsIMsgWindow); + gFilterEditorMsgWindow.domWindow = window; + gFilterEditorMsgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL; + } + return gFilterEditorMsgWindow; +} + +function SetBusyCursor(window, enable) { + // setCursor() is only available for chrome windows. + // However one of our frames is the start page which + // is a non-chrome window, so check if this window has a + // setCursor method + if ("setCursor" in window) { + if (enable) { + window.setCursor("wait"); + } else { + window.setCursor("auto"); + } + } +} + +/* globals openHelp */ +// suite/components/helpviewer/content/contextHelp.js +function doHelpButton() { + openHelp("mail-filters"); +} + +function getCustomActions() { + if (!gCustomActions) { + gCustomActions = MailServices.filters.getCustomActions(); + } +} + +function updateFilterType() { + gFilterType = gFilterTypeSelector.getType(); + setFilterScope(gFilterType, gFilterList); + + // set valid actions + var ruleActions = gFilterActionList.getElementsByAttribute( + "class", + "ruleaction" + ); + for (var i = 0; i < ruleActions.length; i++) { + ruleActions[i].mRuleActionType.hideInvalidActions(); + } +} + +// Given a filter type, set the global search scope to the filter scope +function setFilterScope(aFilterType, aFilterList) { + let filterScope = getFilterScope( + getScopeFromFilterList(aFilterList), + aFilterType, + aFilterList + ); + setSearchScope(filterScope); +} + +// +// Given the base filter scope for a server, and the filter +// type, return the scope used for filter. This assumes a +// hierarchy of contexts, with incoming the most restrictive, +// followed by manual and post-plugin. +function getFilterScope(aServerFilterScope, aFilterType, aFilterList) { + if (aFilterType & Ci.nsMsgFilterType.Incoming) { + return aServerFilterScope; + } + + // Manual or PostPlugin + // local mail allows body and junk types + if (aServerFilterScope == Ci.nsMsgSearchScope.offlineMailFilter) { + return Ci.nsMsgSearchScope.offlineMail; + } + // IMAP and NEWS online don't allow body + return Ci.nsMsgSearchScope.onlineManual; +} + +/** + * Re-focus the action that was focused before focus was lost. + */ +function setLastActionFocus() { + let lastAction = gFilterActionList.getAttribute("focusedAction"); + if (!lastAction || lastAction < 0) { + lastAction = 0; + } + if (lastAction >= gFilterActionList.itemCount) { + lastAction = gFilterActionList.itemCount - 1; + } + + gFilterActionList.getItemAtIndex(lastAction).mRuleActionType.focus(); +} diff --git a/comm/mailnews/search/content/FilterEditor.xhtml b/comm/mailnews/search/content/FilterEditor.xhtml new file mode 100644 index 0000000000..00188392bc --- /dev/null +++ b/comm/mailnews/search/content/FilterEditor.xhtml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + %filterEditorDTD; + + %searchTermDTD; +]> + + + &window.title; + + + + + + + + + + + + + + + + + + + + + + + + + &contextDesc.label; + + + + + + + + + + + + + + + + + + + + + + + + + + + +#include searchTerm.inc.xhtml + + + + + + + + + + diff --git a/comm/mailnews/search/content/searchTerm.inc.xhtml b/comm/mailnews/search/content/searchTerm.inc.xhtml new file mode 100644 index 0000000000..07e3407a4c --- /dev/null +++ b/comm/mailnews/search/content/searchTerm.inc.xhtml @@ -0,0 +1,27 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + + + + + + + + + + + + diff --git a/comm/mailnews/search/content/searchTerm.js b/comm/mailnews/search/content/searchTerm.js new file mode 100644 index 0000000000..10d085a5e0 --- /dev/null +++ b/comm/mailnews/search/content/searchTerm.js @@ -0,0 +1,568 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// abSearchDialog.js +/* globals GetScopeForDirectoryURI */ + +var gTotalSearchTerms = 0; +var gSearchTermList; +var gSearchTerms = []; +var gSearchRemovedTerms = []; +var gSearchScope; +var gSearchBooleanRadiogroup; + +var gUniqueSearchTermCounter = 0; // gets bumped every time we add a search term so we can always +// dynamically generate unique IDs for the terms. + +// cache these so we don't have to hit the string bundle for them +var gMoreButtonTooltipText; +var gLessButtonTooltipText; +var gLoading = true; + +function searchTermContainer() {} + +searchTermContainer.prototype = { + internalSearchTerm: "", + internalBooleanAnd: "", + + // this.searchTerm: the actual nsIMsgSearchTerm object + get searchTerm() { + return this.internalSearchTerm; + }, + set searchTerm(val) { + this.internalSearchTerm = val; + + var term = val; + // val is a nsIMsgSearchTerm + var searchAttribute = this.searchattribute; + var searchOperator = this.searchoperator; + var searchValue = this.searchvalue; + + // now reflect all attributes of the searchterm into the widgets + if (searchAttribute) { + // for custom, the value is the custom id, not the integer attribute + if (term.attrib == Ci.nsMsgSearchAttrib.Custom) { + searchAttribute.value = term.customId; + } else { + searchAttribute.value = term.attrib; + } + } + if (searchOperator) { + searchOperator.value = val.op; + } + if (searchValue) { + searchValue.value = term.value; + } + + this.booleanAnd = val.booleanAnd; + this.matchAll = val.matchAll; + }, + + // searchscope - just forward to the searchattribute + get searchScope() { + if (this.searchattribute) { + return this.searchattribute.searchScope; + } + return undefined; + }, + set searchScope(val) { + var searchAttribute = this.searchattribute; + if (searchAttribute) { + searchAttribute.searchScope = val; + } + }, + + saveId(element, slot) { + this[slot] = element.id; + }, + + getElement(slot) { + return document.getElementById(this[slot]); + }, + + // three well-defined properties: + // searchattribute, searchoperator, searchvalue + // the trick going on here is that we're storing the Element's Id, + // not the element itself, because the XBL object may change out + // from underneath us + get searchattribute() { + return this.getElement("internalSearchAttributeId"); + }, + set searchattribute(val) { + this.saveId(val, "internalSearchAttributeId"); + }, + get searchoperator() { + return this.getElement("internalSearchOperatorId"); + }, + set searchoperator(val) { + this.saveId(val, "internalSearchOperatorId"); + }, + get searchvalue() { + return this.getElement("internalSearchValueId"); + }, + set searchvalue(val) { + this.saveId(val, "internalSearchValueId"); + }, + + booleanNodes: null, + get booleanAnd() { + return this.internalBooleanAnd; + }, + set booleanAnd(val) { + this.internalBooleanAnd = val; + }, + + save() { + var searchTerm = this.searchTerm; + var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib; + + if (isNaN(this.searchattribute.value)) { + // is this a custom term? + searchTerm.attrib = nsMsgSearchAttrib.Custom; + searchTerm.customId = this.searchattribute.value; + } else { + searchTerm.attrib = this.searchattribute.value; + } + + if ( + this.searchattribute.value > nsMsgSearchAttrib.OtherHeader && + this.searchattribute.value < nsMsgSearchAttrib.kNumMsgSearchAttributes + ) { + searchTerm.arbitraryHeader = this.searchattribute.label; + } + searchTerm.op = this.searchoperator.value; + if (this.searchvalue.value) { + this.searchvalue.save(); + } else { + this.searchvalue.saveTo(searchTerm.value); + } + searchTerm.value = this.searchvalue.value; + searchTerm.booleanAnd = this.booleanAnd; + searchTerm.matchAll = this.matchAll; + }, + // if you have a search term element with no search term + saveTo(searchTerm) { + this.internalSearchTerm = searchTerm; + this.save(); + }, +}; + +function initializeSearchWidgets() { + gSearchBooleanRadiogroup = document.getElementById("booleanAndGroup"); + gSearchTermList = document.getElementById("searchTermList"); + + // initialize some strings + var bundle = Services.strings.createBundle( + "chrome://messenger/locale/search.properties" + ); + gMoreButtonTooltipText = bundle.GetStringFromName("moreButtonTooltipText"); + gLessButtonTooltipText = bundle.GetStringFromName("lessButtonTooltipText"); +} + +function initializeBooleanWidgets() { + var booleanAnd = true; + var matchAll = false; + // get the boolean value from the first term + var firstTerm = gSearchTerms[0].searchTerm; + if (firstTerm) { + // If there is a second term, it should actually define whether we're + // using 'and' or not. Note that our UI is not as rich as the + // underlying search model, so there's the potential to lose here when + // grouping is involved. + booleanAnd = + gSearchTerms.length > 1 + ? gSearchTerms[1].searchTerm.booleanAnd + : firstTerm.booleanAnd; + matchAll = firstTerm.matchAll; + } + // target radio items have value="and" or value="or" or "all" + if (matchAll) { + gSearchBooleanRadiogroup.value = "matchAll"; + } else if (booleanAnd) { + gSearchBooleanRadiogroup.value = "and"; + } else { + gSearchBooleanRadiogroup.value = "or"; + } + var searchTerms = document.getElementById("searchTermList"); + if (searchTerms) { + updateSearchTermsListbox(matchAll); + } +} + +function initializeSearchRows(scope, searchTerms) { + for (let i = 0; i < searchTerms.length; i++) { + let searchTerm = searchTerms[i]; + createSearchRow(i, scope, searchTerm, false); + gTotalSearchTerms++; + } + initializeBooleanWidgets(); + updateRemoveRowButton(); +} + +/** + * Enables/disables all the visible elements inside the search terms listbox. + * + * @param matchAllValue boolean value from the first search term + */ +function updateSearchTermsListbox(matchAllValue) { + var searchTerms = document.getElementById("searchTermList"); + searchTerms.setAttribute("disabled", matchAllValue); + var searchAttributeList = + searchTerms.getElementsByTagName("search-attribute"); + var searchOperatorList = searchTerms.getElementsByTagName("search-operator"); + var searchValueList = searchTerms.getElementsByTagName("search-value"); + for (let i = 0; i < searchAttributeList.length; i++) { + searchAttributeList[i].setAttribute("disabled", matchAllValue); + searchOperatorList[i].setAttribute("disabled", matchAllValue); + searchValueList[i].setAttribute("disabled", matchAllValue); + if (!matchAllValue) { + searchValueList[i].removeAttribute("disabled"); + } + } + var moreOrLessButtonsList = searchTerms.getElementsByTagName("button"); + for (let i = 0; i < moreOrLessButtonsList.length; i++) { + moreOrLessButtonsList[i].setAttribute("disabled", matchAllValue); + } + if (!matchAllValue) { + updateRemoveRowButton(); + } +} + +// enables/disables the less button for the first row of search terms. +function updateRemoveRowButton() { + var firstListItem = gSearchTermList.getItemAtIndex(0); + if (firstListItem) { + firstListItem.lastElementChild.lastElementChild.setAttribute( + "disabled", + gTotalSearchTerms == 1 + ); + } +} + +// Returns the actual list item row index in the list of search rows +// that contains the passed in element id. +function getSearchRowIndexForElement(aElement) { + var listItem = aElement; + + while (listItem && listItem.localName != "richlistitem") { + listItem = listItem.parentNode; + } + + return gSearchTermList.getIndexOfItem(listItem); +} + +function onMore(event) { + // if we have an event, extract the list row index and use that as the row number + // for our insertion point. If there is no event, append to the end.... + var rowIndex; + + if (event) { + rowIndex = getSearchRowIndexForElement(event.target) + 1; + } else { + rowIndex = gSearchTermList.getRowCount(); + } + + createSearchRow(rowIndex, gSearchScope, null, event != null); + gTotalSearchTerms++; + updateRemoveRowButton(); + + // the user just added a term, so scroll to it + gSearchTermList.ensureIndexIsVisible(rowIndex); +} + +function onLess(event) { + if (event && gTotalSearchTerms > 1) { + removeSearchRow(getSearchRowIndexForElement(event.target)); + --gTotalSearchTerms; + } + + updateRemoveRowButton(); +} + +// set scope on all visible searchattribute tags +function setSearchScope(scope) { + gSearchScope = scope; + for (var i = 0; i < gSearchTerms.length; i++) { + // don't set element attributes if XBL hasn't loaded + if (!(gSearchTerms[i].obj.searchattribute.searchScope === undefined)) { + gSearchTerms[i].obj.searchattribute.searchScope = scope; + // act like the user "selected" this, see bug #202848 + gSearchTerms[i].obj.searchattribute.onSelect(null /* no event */); + } + gSearchTerms[i].scope = scope; + } +} + +function updateSearchAttributes() { + for (var i = 0; i < gSearchTerms.length; i++) { + gSearchTerms[i].obj.searchattribute.refreshList(); + } +} + +function booleanChanged(event) { + // when boolean changes, we have to update all the attributes on the search terms + var newBoolValue = event.target.getAttribute("value") == "and"; + var matchAllValue = event.target.getAttribute("value") == "matchAll"; + if (document.getElementById("abPopup")) { + var selectedAB = document.getElementById("abPopup").selectedItem.value; + setSearchScope(GetScopeForDirectoryURI(selectedAB)); + } + for (var i = 0; i < gSearchTerms.length; i++) { + let searchTerm = gSearchTerms[i].obj; + // If term is not yet initialized in the UI, change the original object. + if (!searchTerm || !gSearchTerms[i].initialized) { + searchTerm = gSearchTerms[i].searchTerm; + } + + searchTerm.booleanAnd = newBoolValue; + searchTerm.matchAll = matchAllValue; + } + var searchTerms = document.getElementById("searchTermList"); + if (searchTerms) { + if (!matchAllValue && searchTerms.hidden && !gTotalSearchTerms) { + // Fake to get empty row. + onMore(null); + } + updateSearchTermsListbox(matchAllValue); + } +} + +/** + * Create a new search row with all the needed elements. + * + * @param index index of the position in the menulist where to add the row + * @param scope a nsMsgSearchScope constant indicating scope of this search rule + * @param searchTerm nsIMsgSearchTerm object to hold the search term + * @param aUserAdded boolean indicating if the row addition was initiated by the user + * (e.g. via the '+' button) + */ +function createSearchRow(index, scope, searchTerm, aUserAdded) { + var searchAttr = document.createXULElement("search-attribute"); + var searchOp = document.createXULElement("search-operator"); + var searchVal = document.createXULElement("search-value"); + + var moreButton = document.createXULElement("button"); + var lessButton = document.createXULElement("button"); + moreButton.setAttribute("class", "small-button"); + moreButton.setAttribute("oncommand", "onMore(event);"); + moreButton.setAttribute("label", "+"); + moreButton.setAttribute("tooltiptext", gMoreButtonTooltipText); + lessButton.setAttribute("class", "small-button"); + lessButton.setAttribute("oncommand", "onLess(event);"); + lessButton.setAttribute("label", "\u2212"); + lessButton.setAttribute("tooltiptext", gLessButtonTooltipText); + + // now set up ids: + searchAttr.id = "searchAttr" + gUniqueSearchTermCounter; + searchOp.id = "searchOp" + gUniqueSearchTermCounter; + searchVal.id = "searchVal" + gUniqueSearchTermCounter; + + searchAttr.setAttribute("for", searchOp.id + "," + searchVal.id); + searchOp.setAttribute("opfor", searchVal.id); + + var rowdata = [searchAttr, searchOp, searchVal, [moreButton, lessButton]]; + var searchrow = constructRow(rowdata); + searchrow.id = "searchRow" + gUniqueSearchTermCounter; + + var searchTermObj = new searchTermContainer(); + searchTermObj.searchattribute = searchAttr; + searchTermObj.searchoperator = searchOp; + searchTermObj.searchvalue = searchVal; + + // now insert the new search term into our list of terms + gSearchTerms.splice(index, 0, { + obj: searchTermObj, + scope, + searchTerm, + initialized: false, + }); + + var editFilter = window.gFilter || null; + var editMailView = window.gMailView || null; + + if ( + (!editFilter && !editMailView) || + (editFilter && index == gTotalSearchTerms) || + (editMailView && index == gTotalSearchTerms) + ) { + gLoading = false; + } + + // index is index of new row + // gTotalSearchTerms has not been updated yet + if (gLoading || index == gTotalSearchTerms) { + gSearchTermList.appendChild(searchrow); + } else { + var currentItem = gSearchTermList.getItemAtIndex(index); + gSearchTermList.insertBefore(searchrow, currentItem); + } + + // If this row was added by user action, focus the value field. + if (aUserAdded) { + document.commandDispatcher.advanceFocusIntoSubtree(searchVal); + searchrow.setAttribute("highlight", "true"); + } + + // bump our unique search term counter + gUniqueSearchTermCounter++; +} + +function initializeTermFromId(id) { + initializeTermFromIndex( + getSearchRowIndexForElement(document.getElementById(id)) + ); +} + +function initializeTermFromIndex(index) { + var searchTermObj = gSearchTerms[index].obj; + + searchTermObj.searchScope = gSearchTerms[index].scope; + // the search term will initialize the searchTerm element, including + // .booleanAnd + if (gSearchTerms[index].searchTerm) { + searchTermObj.searchTerm = gSearchTerms[index].searchTerm; + // here, we don't have a searchTerm, so it's probably a new element - + // we'll initialize the .booleanAnd from the existing setting in + // the UI + } else { + searchTermObj.booleanAnd = gSearchBooleanRadiogroup.value == "and"; + if (index) { + // If we weren't pre-initialized with a searchTerm then steal the + // search attribute and operator from the previous row. + searchTermObj.searchattribute.value = + gSearchTerms[index - 1].obj.searchattribute.value; + searchTermObj.searchoperator.value = + gSearchTerms[index - 1].obj.searchoperator.value; + } + } + + gSearchTerms[index].initialized = true; +} + +/** + * Creates a using the array children as the children + * of each listcell. + * + * @param aChildren An array of XUL elements to put into the listitem. + * Each array member is put into a separate listcell. + * If the member itself is an array of elements, + * all of them are put into the same listcell. + */ +function constructRow(aChildren) { + let cols = gSearchTermList.firstElementChild.children; // treecol elements + let listitem = document.createXULElement("richlistitem"); + listitem.setAttribute("allowevents", "true"); + for (let i = 0; i < aChildren.length; i++) { + let listcell = document.createXULElement("hbox"); + if (cols[i].hasAttribute("style")) { + listcell.setAttribute("style", cols[i].getAttribute("style")); + } + let child = aChildren[i]; + + if (child instanceof Array) { + for (let j = 0; j < child.length; j++) { + listcell.appendChild(child[j]); + } + } else { + child.setAttribute("flex", "1"); + listcell.appendChild(child); + } + listitem.appendChild(listcell); + } + return listitem; +} + +function removeSearchRow(index) { + var searchTermObj = gSearchTerms[index].obj; + if (!searchTermObj) { + return; + } + + // if it is an existing (but offscreen) term, + // make sure it is initialized before we remove it. + if (!gSearchTerms[index].searchTerm && !gSearchTerms[index].initialized) { + initializeTermFromIndex(index); + } + + // need to remove row from list, so walk upwards from the + // searchattribute to find the first + var listitem = searchTermObj.searchattribute; + + while (listitem) { + if (listitem.localName == "richlistitem") { + break; + } + listitem = listitem.parentNode; + } + + if (!listitem) { + dump("Error: couldn't find parent listitem!\n"); + return; + } + + if (searchTermObj.searchTerm) { + gSearchRemovedTerms.push(searchTermObj.searchTerm); + } else { + // dump("That wasn't real. ignoring \n"); + } + + listitem.remove(); + + // now remove the item from our list of terms + gSearchTerms.splice(index, 1); +} + +/** + * Save the search terms from the UI back to the actual search terms. + * + * @param {nsIMsgSearchTerm[]} searchTerms - Array of terms + * @param {object} termOwner - Object which can contain and create the terms + * e.g. a nsIMsgSearchSession (will be unnecessary if we just make terms + * creatable via XPCOM). + * @returns {nsIMsgSearchTerm[]} The filtered searchTerms. + */ +function saveSearchTerms(searchTerms, termOwner) { + var matchAll = gSearchBooleanRadiogroup.value == "matchAll"; + var i; + + searchTerms = searchTerms.filter(t => !gSearchRemovedTerms.includes(t)); + + for (i = 0; i < gSearchTerms.length; i++) { + try { + gSearchTerms[i].obj.matchAll = matchAll; + var searchTerm = gSearchTerms[i].obj.searchTerm; + if (searchTerm) { + gSearchTerms[i].obj.save(); + } else if (!gSearchTerms[i].initialized) { + // the term might be an offscreen one we haven't initialized yet + searchTerm = gSearchTerms[i].searchTerm; + } else { + // need to create a new searchTerm, and somehow save it to that + searchTerm = termOwner.createTerm(); + gSearchTerms[i].obj.saveTo(searchTerm); + // this might not be the right place for the term, + // but we need to make the array longer anyway + termOwner.appendTerm(searchTerm); + } + searchTerms[i] = searchTerm; + } catch (ex) { + dump("** Error saving element " + i + ": " + ex + "\n"); + } + } + return searchTerms; +} + +function onReset(event) { + while (gTotalSearchTerms > 0) { + removeSearchRow(--gTotalSearchTerms); + } + onMore(null); +} + +function hideMatchAllItem() { + var allItems = document.getElementById("matchAllItem"); + if (allItems) { + allItems.hidden = true; + } +} diff --git a/comm/mailnews/search/content/searchWidgets.js b/comm/mailnews/search/content/searchWidgets.js new file mode 100644 index 0000000000..add3ed29b8 --- /dev/null +++ b/comm/mailnews/search/content/searchWidgets.js @@ -0,0 +1,1779 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements MozXULElement */ + +/* import-globals-from ../../base/content/dateFormat.js */ +// TODO: This is completely bogus. Only one use of this file also has FilterEditor.js. +/* import-globals-from FilterEditor.js */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + const { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); + + const updateParentNode = parentNode => { + if (parentNode.hasAttribute("initialActionIndex")) { + let actionIndex = parentNode.getAttribute("initialActionIndex"); + let filterAction = gFilter.getActionAt(actionIndex); + parentNode.initWithAction(filterAction); + } + parentNode.updateRemoveButton(); + }; + + class MozRuleactiontargetTag extends MozXULElement { + connectedCallback() { + const menulist = document.createXULElement("menulist"); + const menuPopup = document.createXULElement("menupopup"); + + menulist.classList.add("ruleactionitem"); + menulist.setAttribute("flex", "1"); + menulist.appendChild(menuPopup); + + for (let taginfo of MailServices.tags.getAllTags()) { + const newMenuItem = document.createXULElement("menuitem"); + newMenuItem.setAttribute("label", taginfo.tag); + newMenuItem.setAttribute("value", taginfo.key); + if (taginfo.color) { + newMenuItem.setAttribute("style", `color: ${taginfo.color};`); + } + menuPopup.appendChild(newMenuItem); + } + + this.appendChild(menulist); + + updateParentNode(this.closest(".ruleaction")); + } + } + + class MozRuleactiontargetPriority extends MozXULElement { + connectedCallback() { + this.appendChild( + MozXULElement.parseXULToFragment( + ` + + + + + + + + + + `, + ["chrome://messenger/locale/FilterEditor.dtd"] + ) + ); + + updateParentNode(this.closest(".ruleaction")); + } + } + + class MozRuleactiontargetJunkscore extends MozXULElement { + connectedCallback() { + this.appendChild( + MozXULElement.parseXULToFragment( + ` + + + + + + + `, + ["chrome://messenger/locale/FilterEditor.dtd"] + ) + ); + + updateParentNode(this.closest(".ruleaction")); + } + } + + class MozRuleactiontargetReplyto extends MozXULElement { + connectedCallback() { + const menulist = document.createXULElement("menulist"); + const menuPopup = document.createXULElement("menupopup"); + + menulist.classList.add("ruleactionitem"); + menulist.setAttribute("flex", "1"); + menulist.appendChild(menuPopup); + + this.appendChild(menulist); + + let ruleaction = this.closest(".ruleaction"); + let raMenulist = ruleaction.querySelector( + '[is="ruleactiontype-menulist"]' + ); + for (let { label, value } of raMenulist.findTemplates()) { + menulist.appendItem(label, value); + } + updateParentNode(ruleaction); + } + } + + class MozRuleactiontargetForwardto extends MozXULElement { + connectedCallback() { + const input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.classList.add("ruleactionitem", "input-inline"); + + this.classList.add("input-container"); + this.appendChild(input); + + updateParentNode(this.closest(".ruleaction")); + } + } + + class MozRuleactiontargetFolder extends MozXULElement { + connectedCallback() { + this.appendChild( + MozXULElement.parseXULToFragment( + ` + + + + + `, + ["chrome://messenger/locale/messenger.dtd"] + ) + ); + + this.menulist = this.querySelector("menulist"); + + this.menulist.addEventListener("command", event => { + this.setPicker(event); + }); + + updateParentNode(this.closest(".ruleaction")); + + let folder = this.menulist.value + ? MailUtils.getOrCreateFolder(this.menulist.value) + : gFilterList.folder; + + // An account folder is not a move/copy target; show "Choose Folder". + folder = folder.isServer ? null : folder; + + this.menulist.menupopup.selectFolder(folder); + } + + setPicker(event) { + this.menulist.menupopup.selectFolder(event.target._folder); + } + } + + class MozRuleactiontargetWrapper extends MozXULElement { + static get observedAttributes() { + return ["type"]; + } + + get ruleactiontargetElement() { + return this.node; + } + + connectedCallback() { + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _getChildNode(type) { + const elementMapping = { + movemessage: "ruleactiontarget-folder", + copymessage: "ruleactiontarget-folder", + setpriorityto: "ruleactiontarget-priority", + setjunkscore: "ruleactiontarget-junkscore", + forwardmessage: "ruleactiontarget-forwardto", + replytomessage: "ruleactiontarget-replyto", + addtagtomessage: "ruleactiontarget-tag", + }; + const elementName = elementMapping[type]; + + return elementName ? document.createXULElement(elementName) : null; + } + + _updateAttributes() { + if (!this.hasAttribute("type")) { + return; + } + + const type = this.getAttribute("type"); + + while (this.lastChild) { + this.lastChild.remove(); + } + + if (type == null) { + return; + } + + this.node = this._getChildNode(type); + + if (this.node) { + this.node.setAttribute("flex", "1"); + this.appendChild(this.node); + } else { + updateParentNode(this.closest(".ruleaction")); + } + } + } + + customElements.define("ruleactiontarget-tag", MozRuleactiontargetTag); + customElements.define( + "ruleactiontarget-priority", + MozRuleactiontargetPriority + ); + customElements.define( + "ruleactiontarget-junkscore", + MozRuleactiontargetJunkscore + ); + customElements.define("ruleactiontarget-replyto", MozRuleactiontargetReplyto); + customElements.define( + "ruleactiontarget-forwardto", + MozRuleactiontargetForwardto + ); + customElements.define("ruleactiontarget-folder", MozRuleactiontargetFolder); + customElements.define("ruleactiontarget-wrapper", MozRuleactiontargetWrapper); + + /** + * This is an abstract class for search menulist general functionality. + * + * @abstract + * @augments MozXULElement + */ + class MozSearchMenulistAbstract extends MozXULElement { + static get observedAttributes() { + return ["flex", "disabled"]; + } + + constructor() { + super(); + this.internalScope = null; + this.internalValue = -1; + this.validityManager = Cc[ + "@mozilla.org/mail/search/validityManager;1" + ].getService(Ci.nsIMsgSearchValidityManager); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.menulist = document.createXULElement("menulist"); + this.menulist.classList.add("search-menulist"); + this.menulist.addEventListener("command", this.onSelect.bind(this)); + this.menupopup = document.createXULElement("menupopup"); + this.menupopup.classList.add("search-menulist-popup"); + this.menulist.appendChild(this.menupopup); + this.appendChild(this.menulist); + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.menulist) { + return; + } + if (this.hasAttribute("flex")) { + this.menulist.setAttribute("flex", this.getAttribute("flex")); + } else { + this.menulist.removeAttribute("flex"); + } + if (this.hasAttribute("disabled")) { + this.menulist.setAttribute("disabled", this.getAttribute("disabled")); + } else { + this.menulist.removeAttribute("disabled"); + } + } + + set searchScope(val) { + // if scope isn't changing this is a noop + if (this.internalScope == val) { + return; + } + this.internalScope = val; + this.refreshList(); + if (this.targets) { + this.targets.forEach(target => { + customElements.upgrade(target); + target.searchScope = val; + }); + } + } + + get searchScope() { + return this.internalScope; + } + + get validityTable() { + return this.validityManager.getTable(this.searchScope); + } + + get targets() { + const forAttrs = this.getAttribute("for"); + if (!forAttrs) { + return null; + } + const targetIds = forAttrs.split(","); + if (targetIds.length == 0) { + return null; + } + + return targetIds + .map(id => document.getElementById(id)) + .filter(e => e != null); + } + + get optargets() { + const forAttrs = this.getAttribute("opfor"); + if (!forAttrs) { + return null; + } + const optargetIds = forAttrs.split(","); + if (optargetIds.length == 0) { + return null; + } + + return optargetIds + .map(id => document.getElementById(id)) + .filter(e => e != null); + } + + set value(val) { + if (this.internalValue == val) { + return; + } + this.internalValue = val; + this.menulist.selectedItem = this.validMenuitem; + // now notify targets of new parent's value + if (this.targets) { + this.targets.forEach(target => { + customElements.upgrade(target); + target.parentValue = val; + }); + } + // now notify optargets of new op parent's value + if (this.optargets) { + this.optargets.forEach(optarget => { + customElements.upgrade(optarget); + optarget.opParentValue = val; + }); + } + } + + get value() { + return this.internalValue; + } + + /** + * Gets the label of the menulist's selected item. + */ + get label() { + return this.menulist.selectedItem.getAttribute("label"); + } + + get validMenuitem() { + if (this.value == -1) { + // -1 means not initialized + return null; + } + let isCustom = isNaN(this.value); + let typedValue = isCustom ? this.value : parseInt(this.value); + // custom attribute to style the unavailable menulist item + this.menulist.setAttribute( + "unavailable", + !this.valueIds.includes(typedValue) ? "true" : null + ); + // add a hidden menulist item if value is missing + let menuitem = this.menulist.querySelector(`[value="${this.value}"]`); + if (!menuitem) { + // need to add a hidden menuitem + menuitem = this.menulist.appendItem(this.valueLabel, this.value); + menuitem.hidden = true; + } + return menuitem; + } + + refreshList(dontRestore) { + const menuItemIds = this.valueIds; + const menuItemStrings = this.valueStrings; + const popup = this.menupopup; + // save our old "value" so we can restore it later + let oldData; + if (!dontRestore) { + oldData = this.menulist.value; + } + // remove the old popup children + while (popup.hasChildNodes()) { + popup.lastChild.remove(); + } + let newSelection; + let customizePos = -1; + for (let i = 0; i < menuItemIds.length; i++) { + // create the menuitem + if (Ci.nsMsgSearchAttrib.OtherHeader == menuItemIds[i].toString()) { + customizePos = i; + } else { + const menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", menuItemStrings[i]); + menuitem.setAttribute("value", menuItemIds[i]); + popup.appendChild(menuitem); + // try to restore the selection + if (!newSelection || oldData == menuItemIds[i].toString()) { + newSelection = menuitem; + } + } + } + if (customizePos != -1) { + const separator = document.createXULElement("menuseparator"); + popup.appendChild(separator); + const menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", menuItemStrings[customizePos]); + menuitem.setAttribute("value", menuItemIds[customizePos]); + popup.appendChild(menuitem); + } + + // If we are either uninitialized, or if we are called because + // of a change in our parent, update the value to the + // default stored in newSelection. + if ((this.value == -1 || dontRestore) && newSelection) { + this.value = newSelection.getAttribute("value"); + } + this.menulist.selectedItem = this.validMenuitem; + } + + onSelect(event) { + if (this.menulist.value == Ci.nsMsgSearchAttrib.OtherHeader) { + // Customize menuitem selected. + let args = {}; + window.openDialog( + "chrome://messenger/content/CustomHeaders.xhtml", + "", + "modal,centerscreen,resizable,titlebar,chrome", + args + ); + // User may have removed the custom header currently selected + // in the menulist so temporarily set the selection to a safe value. + this.value = Ci.nsMsgSearchAttrib.OtherHeader; + // rebuild the menulist + UpdateAfterCustomHeaderChange(); + // Find the created or chosen custom header and select it. + let menuitem = null; + if (args.selectedVal) { + menuitem = this.menulist.querySelector( + `[label="${args.selectedVal}"]` + ); + } + if (menuitem) { + this.value = menuitem.value; + } else { + // Nothing was picked in the custom headers editor so just pick something + // instead of the current "Customize" menuitem. + this.value = this.menulist.getItemAtIndex(0).value; + } + } else { + this.value = this.menulist.value; + } + } + } + + /** + * The MozSearchAttribute widget is typically used in the search and filter dialogs to show a list + * of possible message headers. + * + * @augments MozSearchMenulistAbstract + */ + class MozSearchAttribute extends MozSearchMenulistAbstract { + constructor() { + super(); + + this.stringBundle = Services.strings.createBundle( + "chrome://messenger/locale/search-attributes.properties" + ); + } + + connectedCallback() { + super.connectedCallback(); + + initializeTermFromId(this.id); + } + + get valueLabel() { + if (isNaN(this.value)) { + // is this a custom term? + let customTerm = MailServices.filters.getCustomTerm(this.value); + if (customTerm) { + return customTerm.name; + } + // The custom term may be missing after the extension that added it + // was disabled or removed. We need to notify the user. + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + "Missing custom search term " + this.value, + null, + null, + 0, + 0, + Ci.nsIScriptError.errorFlag, + "component javascript" + ); + Services.console.logMessage(scriptError); + return this.stringBundle.GetStringFromName("MissingCustomTerm"); + } + return this.stringBundle.GetStringFromName( + this.validityManager.getAttributeProperty(parseInt(this.value)) + ); + } + + get valueIds() { + let result = this.validityTable.getAvailableAttributes(); + // add any available custom search terms + for (let customTerm of MailServices.filters.getCustomTerms()) { + // For custom terms, the array element is a string with the custom id + // instead of the integer attribute + if (customTerm.getAvailable(this.searchScope, null)) { + result.push(customTerm.id); + } + } + return result; + } + + get valueStrings() { + let strings = []; + let ids = this.valueIds; + let hdrsArray = null; + try { + let hdrs = Services.prefs.getCharPref("mailnews.customHeaders"); + hdrs = hdrs.replace(/\s+/g, ""); // remove white spaces before splitting + hdrsArray = hdrs.match(/[^:]+/g); + } catch (ex) {} + let j = 0; + for (let i = 0; i < ids.length; i++) { + if (isNaN(ids[i])) { + // Is this a custom search term? + let customTerm = MailServices.filters.getCustomTerm(ids[i]); + if (customTerm) { + strings[i] = customTerm.name; + } else { + strings[i] = ""; + } + } else if (ids[i] > Ci.nsMsgSearchAttrib.OtherHeader && hdrsArray) { + strings[i] = hdrsArray[j++]; + } else { + strings[i] = this.stringBundle.GetStringFromName( + this.validityManager.getAttributeProperty(ids[i]) + ); + } + } + return strings; + } + } + customElements.define("search-attribute", MozSearchAttribute); + + /** + * MozSearchOperator contains a list of operators that can be applied on search-attribute and + * search-value value. + * + * @augments MozSearchMenulistAbstract + */ + class MozSearchOperator extends MozSearchMenulistAbstract { + constructor() { + super(); + + this.stringBundle = Services.strings.createBundle( + "chrome://messenger/locale/search-operators.properties" + ); + } + + connectedCallback() { + super.connectedCallback(); + + this.searchAttribute = Ci.nsMsgSearchAttrib.Default; + } + + get valueLabel() { + return this.stringBundle.GetStringFromName(this.value); + } + + get valueIds() { + let isCustom = isNaN(this.searchAttribute); + if (isCustom) { + let customTerm = MailServices.filters.getCustomTerm( + this.searchAttribute + ); + if (customTerm) { + return customTerm.getAvailableOperators(this.searchScope); + } + return [Ci.nsMsgSearchOp.Contains]; + } + return this.validityTable.getAvailableOperators(this.searchAttribute); + } + + get valueStrings() { + let strings = []; + let ids = this.valueIds; + for (let i = 0; i < ids.length; i++) { + strings[i] = this.stringBundle.GetStringFromID(ids[i]); + } + return strings; + } + + set parentValue(val) { + if ( + this.searchAttribute == val && + val != Ci.nsMsgSearchAttrib.OtherHeader + ) { + return; + } + this.searchAttribute = val; + this.refreshList(true); // don't restore the selection, since searchvalue nulls it + if (val == Ci.nsMsgSearchAttrib.AgeInDays) { + // We want "Age in Days" to default to "is less than". + this.value = Ci.nsMsgSearchOp.IsLessThan; + } + } + + get parentValue() { + return this.searchAttribute; + } + } + customElements.define("search-operator", MozSearchOperator); + + /** + * MozSearchValue is a widget that allows selecting the value to search or filter on. It can be a + * text entry, priority, status, junk status, tags, hasAttachment status, and addressbook etc. + * + * @augments MozXULElement + */ + class MozSearchValue extends MozXULElement { + static get observedAttributes() { + return ["disabled"]; + } + + constructor() { + super(); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + onEnterInSearchTerm(event); + }); + + this.internalOperator = null; + this.internalAttribute = null; + this.internalValue = null; + + this.inputType = "none"; + } + + connectedCallback() { + this.classList.add("input-container"); + } + + static get stringBundle() { + if (!this._stringBundle) { + this._stringBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + } + return this._stringBundle; + } + + /** + * Create a menulist to be used as the input. + * + * @param {object[]} itemDataList - An ordered list of items to add to the + * menulist. Each entry must have a 'value' property to be used as the + * item value. If the entry has a 'label' property, it will be used + * directly as the item label, otherwise it must identify a bundle string + * using the 'stringId' property. + * + * @returns {MozMenuList} - The newly created menulist. + */ + static _createMenulist(itemDataList) { + let menulist = document.createXULElement("menulist"); + menulist.classList.add("search-value-menulist"); + let menupopup = document.createXULElement("menupopup"); + menupopup.classList.add("search-value-popup"); + + let bundle = this.stringBundle; + + for (let itemData of itemDataList) { + let item = document.createXULElement("menuitem"); + item.classList.add("search-value-menuitem"); + item.label = + itemData.label || bundle.GetStringFromName(itemData.stringId); + item.value = itemData.value; + menupopup.appendChild(item); + } + menulist.appendChild(menupopup); + return menulist; + } + + /** + * Set the child input. The input will only be changed if the type changes. + * + * @param {string} type - The type of input to use. + * @param {string|number|undefined} value - A value to set on the input, or + * leave undefined to not change the value. See setInputValue. + */ + setInput(type, value) { + if (type != this.inputType) { + this.inputType = type; + this.input?.remove(); + let input; + switch (type) { + case "text": + input = document.createElement("input"); + input.classList.add("input-inline", "search-value-input"); + break; + case "date": + input = document.createElement("input"); + input.classList.add("input-inline", "search-value-input"); + if (!value) { + // Newly created date input shows today's date. + // value is expected in microseconds since epoch. + value = Date.now() * 1000; + } + break; + case "size": + input = document.createElement("input"); + input.type = "number"; + input.min = 0; + input.max = 1000000000; + input.classList.add("input-inline", "search-value-input"); + break; + case "age": + input = document.createElement("input"); + input.type = "number"; + input.min = -40000; // ~100 years. + input.max = 40000; + input.classList.add("input-inline", "search-value-input"); + break; + case "percent": + input = document.createElement("input"); + input.type = "number"; + input.min = 0; + input.max = 100; + input.classList.add("input-inline", "search-value-input"); + break; + case "priority": + input = this.constructor._createMenulist([ + { stringId: "priorityHighest", value: Ci.nsMsgPriority.highest }, + { stringId: "priorityHigh", value: Ci.nsMsgPriority.high }, + { stringId: "priorityNormal", value: Ci.nsMsgPriority.normal }, + { stringId: "priorityLow", value: Ci.nsMsgPriority.low }, + { stringId: "priorityLowest", value: Ci.nsMsgPriority.lowest }, + ]); + break; + case "status": + input = this.constructor._createMenulist([ + { stringId: "replied", value: Ci.nsMsgMessageFlags.Replied }, + { stringId: "read", value: Ci.nsMsgMessageFlags.Read }, + { stringId: "new", value: Ci.nsMsgMessageFlags.New }, + { stringId: "forwarded", value: Ci.nsMsgMessageFlags.Forwarded }, + { stringId: "flagged", value: Ci.nsMsgMessageFlags.Marked }, + ]); + break; + case "addressbook": + input = document.createXULElement("menulist", { + is: "menulist-addrbooks", + }); + input.setAttribute("localonly", "true"); + input.classList.add("search-value-menulist"); + if (!value) { + // Select the personal addressbook by default. + value = "jsaddrbook://abook.sqlite"; + } + break; + case "tags": + input = this.constructor._createMenulist( + MailServices.tags.getAllTags().map(taginfo => { + return { label: taginfo.tag, value: taginfo.key }; + }) + ); + break; + case "junk-status": + // "Junk Status is/isn't/is empty/isn't empty 'Junk'". + input = this.constructor._createMenulist([ + { stringId: "junk", value: Ci.nsIJunkMailPlugin.JUNK }, + ]); + break; + case "attachment-status": + // "Attachment Status is/isn't 'Has Attachments'". + input = this.constructor._createMenulist([ + { stringId: "hasAttachments", value: "0" }, + ]); + break; + case "junk-origin": + input = this.constructor._createMenulist([ + { stringId: "junkScoreOriginPlugin", value: "plugin" }, + { stringId: "junkScoreOriginUser", value: "user" }, + { stringId: "junkScoreOriginFilter", value: "filter" }, + { stringId: "junkScoreOriginWhitelist", value: "whitelist" }, + { stringId: "junkScoreOriginImapFlag", value: "imapflag" }, + ]); + break; + case "none": + input = null; + break; + case "custom": + // Used by extensions. + // FIXME: We need a better way for extensions to set a custom input. + input = document.createXULElement("hbox"); + input.setAttribute("flex", "1"); + input.classList.add("search-value-custom"); + break; + default: + throw new Error(`Unrecognised input type "${type}"`); + } + + this.input = input; + if (input) { + this.appendChild(input); + } + + this._updateAttributes(); + } + + this.setInputValue(value); + } + + /** + * Set the child input to the given value. + * + * @param {string|number} value - The value to set on the input. For "date" + * inputs, this should be a number of microseconds since the epoch. + */ + setInputValue(value) { + if (value === undefined) { + return; + } + switch (this.inputType) { + case "text": + case "size": + case "age": + case "percent": + this.input.value = value; + break; + case "date": + this.input.value = convertPRTimeToString(value); + break; + case "priority": + case "status": + case "addressbook": + case "tags": + case "junk-status": + case "attachment-status": + case "junk-origin": + let item = this.input.querySelector(`menuitem[value="${value}"]`); + if (item) { + this.input.selectedItem = item; + } + break; + case "none": + // Silently ignore the value. + break; + case "custom": + this.input.setAttribute("value", value); + break; + default: + throw new Error(`Unhandled input type "${this.inputType}"`); + } + } + + /** + * Get the child input's value. + * + * @returns {string|number} - The value set in the input. For "date" + * inputs, this is the number of microseconds since the epoch. + */ + getInputValue() { + switch (this.inputType) { + case "text": + case "size": + case "age": + case "percent": + return this.input.value; + case "date": + return convertStringToPRTime(this.input.value); + case "priority": + case "status": + case "addressbook": + case "tags": + case "junk-status": + case "attachment-status": + case "junk-origin": + return this.input.selectedItem.value; + case "none": + return ""; + case "custom": + return this.input.getAttribute("value"); + default: + throw new Error(`Unhandled input type "${this.inputType}"`); + } + } + + /** + * Get the element's displayed value. + * + * @returns {string} - The value seen by the user. + */ + getReadableValue() { + switch (this.inputType) { + case "text": + case "size": + case "age": + case "percent": + case "date": + return this.input.value; + case "priority": + case "status": + case "addressbook": + case "tags": + case "junk-status": + case "attachment-status": + case "junk-origin": + return this.input.selectedItem.label; + case "none": + return ""; + case "custom": + return this.input.getAttribute("value"); + default: + throw new Error(`Unhandled input type "${this.inputType}"`); + } + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.input) { + return; + } + if (this.hasAttribute("disabled")) { + this.input.setAttribute("disabled", this.getAttribute("disabled")); + } else { + this.input.removeAttribute("disabled"); + } + } + + /** + * Update the displayed input according to the selected sibling attributes + * and operators. + * + * @param {nsIMsgSearchValue} [value] - A value to display in the input. Or + * leave unset to not change the value. + */ + updateDisplay(value) { + let operator = Number(this.internalOperator); + switch (Number(this.internalAttribute)) { + // Use the index to hide/show the appropriate child. + case Ci.nsMsgSearchAttrib.Priority: + this.setInput("priority", value?.priority); + break; + case Ci.nsMsgSearchAttrib.MsgStatus: + this.setInput("status", value?.status); + break; + case Ci.nsMsgSearchAttrib.Date: + this.setInput("date", value?.date); + break; + case Ci.nsMsgSearchAttrib.Sender: + case Ci.nsMsgSearchAttrib.To: + case Ci.nsMsgSearchAttrib.ToOrCC: + case Ci.nsMsgSearchAttrib.AllAddresses: + case Ci.nsMsgSearchAttrib.CC: + if ( + operator == Ci.nsMsgSearchOp.IsntInAB || + operator == Ci.nsMsgSearchOp.IsInAB + ) { + this.setInput("addressbook", value?.str); + } else { + this.setInput("text", value?.str); + } + break; + case Ci.nsMsgSearchAttrib.Keywords: + this.setInput( + operator == Ci.nsMsgSearchOp.IsEmpty || + operator == Ci.nsMsgSearchOp.IsntEmpty + ? "none" + : "tags", + value?.str + ); + break; + case Ci.nsMsgSearchAttrib.JunkStatus: + this.setInput( + operator == Ci.nsMsgSearchOp.IsEmpty || + operator == Ci.nsMsgSearchOp.IsntEmpty + ? "none" + : "junk-status", + value?.junkStatus + ); + break; + case Ci.nsMsgSearchAttrib.HasAttachmentStatus: + this.setInput("attachment-status", value?.hasAttachmentStatus); + break; + case Ci.nsMsgSearchAttrib.JunkScoreOrigin: + this.setInput("junk-origin", value?.str); + break; + case Ci.nsMsgSearchAttrib.AgeInDays: + this.setInput("age", value?.age); + break; + case Ci.nsMsgSearchAttrib.Size: + this.setInput("size", value?.size); + break; + case Ci.nsMsgSearchAttrib.JunkPercent: + this.setInput("percent", value?.junkPercent); + break; + default: + if (isNaN(this.internalAttribute)) { + // Custom attribute, the internalAttribute is a string. + // FIXME: We need a better way for extensions to set a custom input. + this.setInput("custom", value?.str); + this.input.setAttribute("searchAttribute", this.internalAttribute); + } else { + this.setInput("text", value?.str); + } + break; + } + } + + /** + * The sibling operator type. + * + * @type {nsMsgSearchOpValue} + */ + set opParentValue(val) { + if (this.internalOperator == val) { + return; + } + this.internalOperator = val; + this.updateDisplay(); + } + + get opParentValue() { + return this.internalOperator; + } + + /** + * A duplicate of the searchAttribute property. + * + * @type {nsMsgSearchAttribValue} + */ + set parentValue(val) { + this.searchAttribute = val; + } + + get parentValue() { + return this.searchAttribute; + } + + /** + * The sibling attribute type. + * + * @type {nsMsgSearchAttribValue} + */ + set searchAttribute(val) { + if (this.internalAttribute == val) { + return; + } + this.internalAttribute = val; + this.updateDisplay(); + } + + get searchAttribute() { + return this.internalAttribute; + } + + /** + * The stored value for this element. + * + * Note that the input value is *derived* from this object when it is set. + * But changes to the input value using the UI will not change the stored + * value until the save method is called. + * + * @type {nsIMsgSearchValue} + */ + set value(val) { + // val is a nsIMsgSearchValue object + this.internalValue = val; + this.updateDisplay(val); + } + + get value() { + return this.internalValue; + } + + /** + * Updates the stored value for this element to reflect its current input + * value. + */ + save() { + let searchValue = this.value; + let searchAttribute = this.searchAttribute; + + searchValue.attrib = isNaN(searchAttribute) + ? Ci.nsMsgSearchAttrib.Custom + : searchAttribute; + switch (Number(searchAttribute)) { + case Ci.nsMsgSearchAttrib.Priority: + searchValue.priority = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.MsgStatus: + searchValue.status = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.AgeInDays: + searchValue.age = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.Date: + searchValue.date = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.JunkStatus: + searchValue.junkStatus = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.HasAttachmentStatus: + searchValue.status = Ci.nsMsgMessageFlags.Attachment; + break; + case Ci.nsMsgSearchAttrib.JunkPercent: + searchValue.junkPercent = this.getInputValue(); + break; + case Ci.nsMsgSearchAttrib.Size: + searchValue.size = this.getInputValue(); + break; + default: + searchValue.str = this.getInputValue(); + break; + } + } + + /** + * Stores the displayed value for this element in the given object. + * + * Note that after this call, the stored value will remain pointing to the + * given searchValue object. + * + * @param {nsIMsgSearchValue} searchValue - The object to store the + * displayed value in. + */ + saveTo(searchValue) { + this.internalValue = searchValue; + this.save(); + } + } + customElements.define("search-value", MozSearchValue); + + // The menulist CE is defined lazily. Create one now to get menulist defined, + // allowing us to inherit from it. + if (!customElements.get("menulist")) { + delete document.createXULElement("menulist"); + } + { + /** + * The MozRuleactiontypeMenulist is a widget that allows selecting the actions from the given menulist for + * the selected folder. It gets displayed in the message filter dialog box. + * + * @augments {MozMenuList} + */ + class MozRuleactiontypeMenulist extends customElements.get("menulist") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "ruleactiontype-menulist"); + this.addEventListener("command", event => { + this.parentNode.setAttribute("value", this.value); + checkActionsReorder(); + }); + + this.addEventListener("popupshowing", event => { + let unavailableActions = this.usedActionsList(); + for (let index = 0; index < this.menuitems.length; index++) { + let menu = this.menuitems[index]; + menu.setAttribute("disabled", menu.value in unavailableActions); + } + }); + + this.menuitems = this.getElementsByTagNameNS( + this.namespaceURI, + "menuitem" + ); + + // Force initialization of the menulist custom element first. + customElements.upgrade(this); + this.addCustomActions(); + this.hideInvalidActions(); + // Differentiate between creating a new, next available action, + // and creating a row which will be initialized with an action. + if (!this.parentNode.hasAttribute("initialActionIndex")) { + let unavailableActions = this.usedActionsList(); + // Select the first one that's not in the list. + for (let index = 0; index < this.menuitems.length; index++) { + let menu = this.menuitems[index]; + if (!(menu.value in unavailableActions) && !menu.hidden) { + this.value = menu.value; + this.parentNode.setAttribute("value", menu.value); + break; + } + } + } else { + this.parentNode.mActionTypeInitialized = true; + this.parentNode.clearInitialActionIndex(); + } + } + + hideInvalidActions() { + let menupopup = this.menupopup; + let scope = getScopeFromFilterList(gFilterList); + + // Walk through the list of filter actions and hide any actions which aren't valid + // for our given scope (news, imap, pop, etc) and context. + let elements; + + // Disable / enable all elements in the "filteractionlist" + // based on the scope and the "enablefornews" attribute. + elements = menupopup.getElementsByAttribute("enablefornews", "true"); + for (let i = 0; i < elements.length; i++) { + elements[i].hidden = scope != Ci.nsMsgSearchScope.newsFilter; + } + + elements = menupopup.getElementsByAttribute("enablefornews", "false"); + for (let i = 0; i < elements.length; i++) { + elements[i].hidden = scope == Ci.nsMsgSearchScope.newsFilter; + } + + elements = menupopup.getElementsByAttribute("enableforpop3", "true"); + for (let i = 0; i < elements.length; i++) { + elements[i].hidden = !( + gFilterList.folder.server.type == "pop3" || + gFilterList.folder.server.type == "none" + ); + } + + elements = menupopup.getElementsByAttribute("isCustom", "true"); + // Note there might be an additional element here as a placeholder + // for a missing action, so we iterate over the known actions + // instead of the elements. + for (let i = 0; i < gCustomActions.length; i++) { + elements[i].hidden = !gCustomActions[i].isValidForType( + gFilterType, + scope + ); + } + + // Disable "Reply with Template" if there are no templates. + if (this.findTemplates().length == 0) { + elements = menupopup.getElementsByAttribute( + "value", + "replytomessage" + ); + if (elements.length == 1) { + elements[0].hidden = true; + } + } + } + + addCustomActions() { + let menupopup = this.menupopup; + for (let i = 0; i < gCustomActions.length; i++) { + let customAction = gCustomActions[i]; + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", customAction.name); + menuitem.setAttribute("value", customAction.id); + menuitem.setAttribute("isCustom", "true"); + menupopup.appendChild(menuitem); + } + } + + /** + * Returns a hash containing all of the filter actions which are currently + * being used by other filteractionrows. + * + * @returns {object} - a hash containing all of the filter actions which are + * currently being used by other filteractionrows. + */ + usedActionsList() { + let usedActions = {}; + let currentFilterActionRow = this.parentNode; + let listBox = currentFilterActionRow.parentNode; // need to account for the list item. + // Now iterate over each list item in the list box. + for (let index = 0; index < listBox.getRowCount(); index++) { + let filterActionRow = listBox.getItemAtIndex(index); + if (filterActionRow != currentFilterActionRow) { + let actionValue = filterActionRow.getAttribute("value"); + + // Let custom actions decide if dups are allowed. + let isCustom = false; + for (let i = 0; i < gCustomActions.length; i++) { + if (gCustomActions[i].id == actionValue) { + isCustom = true; + if (!gCustomActions[i].allowDuplicates) { + usedActions[actionValue] = true; + } + break; + } + } + + if (!isCustom) { + // The following actions can appear more than once in a single filter + // so do not set them as already used. + if ( + actionValue != "addtagtomessage" && + actionValue != "forwardmessage" && + actionValue != "copymessage" + ) { + usedActions[actionValue] = true; + } + // If either Delete message or Move message exists, disable the other one. + // It does not make sense to apply both to the same message. + if (actionValue == "deletemessage") { + usedActions.movemessage = true; + } else if (actionValue == "movemessage") { + usedActions.deletemessage = true; + } else if (actionValue == "markasread") { + // The same with Mark as read/Mark as Unread. + usedActions.markasunread = true; + } else if (actionValue == "markasunread") { + usedActions.markasread = true; + } + } + } + } + return usedActions; + } + + /** + * Check if there exist any templates in this account. + * + * @returns {object[]} - An array of template headers: each has a label and + * a value. + */ + findTemplates() { + let identities = MailServices.accounts.getIdentitiesForServer( + gFilterList.folder.server + ); + // Typically if this is Local Folders. + if (identities.length == 0) { + if (MailServices.accounts.defaultAccount) { + identities.push( + MailServices.accounts.defaultAccount.defaultIdentity + ); + } + } + + let templates = []; + let foldersScanned = []; + + for (let identity of identities) { + let enumerator = null; + let msgFolder = MailUtils.getExistingFolder( + identity.stationeryFolder + ); + // If we already processed this folder, do not set enumerator + // so that we skip this identity. + if (msgFolder && !foldersScanned.includes(msgFolder)) { + foldersScanned.push(msgFolder); + enumerator = msgFolder.msgDatabase.enumerateMessages(); + } + + if (!enumerator) { + continue; + } + + for (let header of enumerator) { + let uri = + msgFolder.URI + + "?messageId=" + + header.messageId + + "&subject=" + + header.mime2DecodedSubject; + templates.push({ label: header.mime2DecodedSubject, value: uri }); + } + } + return templates; + } + } + + customElements.define( + "ruleactiontype-menulist", + MozRuleactiontypeMenulist, + { extends: "menulist" } + ); + } + + /** + * The MozRuleactionRichlistitem is a widget which gives the options to filter + * the messages with following elements: ruleactiontype-menulist, ruleactiontarget-wrapper + * and button to add or remove the MozRuleactionRichlistitem. It gets added in the + * filterActionList richlistbox in the Filter Editor dialog. + * + * @augments {MozElements.MozRichlistitem} + */ + class MozRuleactionRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { ".ruleactiontarget": "type=value" }; + } + + constructor() { + super(); + + this.mActionTypeInitialized = false; + this.mRuleActionTargetInitialized = false; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.setAttribute("is", "ruleaction-richlistitem"); + this.appendChild( + MozXULElement.parseXULToFragment( + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + [ + "chrome://messenger/locale/messenger.dtd", + "chrome://messenger/locale/FilterEditor.dtd", + ] + ) + ); + + this.mRuleActionType = this.querySelector("menulist"); + this.mRemoveButton = this.querySelector(".remove-small-button"); + this.mListBox = this.parentNode; + this.initializeAttributeInheritance(); + } + + set selected(val) { + // This provides a dummy selected property that the richlistbox expects to + // be able to call. See bug 202036. + } + + get selected() { + return false; + } + + _fireEvent(aName) { + // This provides a dummy _fireEvent function that the richlistbox expects to + // be able to call. See bug 202036. + } + + /** + * We should only remove the initialActionIndex after we have been told that + * both the rule action type and the rule action target have both been built + * since they both need this piece of information. This complication arises + * because both of these child elements are getting bound asynchronously + * after the search row has been constructed. + */ + clearInitialActionIndex() { + if (this.mActionTypeInitialized && this.mRuleActionTargetInitialized) { + this.removeAttribute("initialActionIndex"); + } + } + + initWithAction(aFilterAction) { + let filterActionStr; + let actionTarget = this.children[1]; + let actionItem = actionTarget.ruleactiontargetElement; + let nsMsgFilterAction = Ci.nsMsgFilterAction; + switch (aFilterAction.type) { + case nsMsgFilterAction.Custom: + filterActionStr = aFilterAction.customId; + if (actionItem) { + actionItem.children[0].value = aFilterAction.strValue; + } + + // Make sure the custom action has been added. If not, it + // probably was from an extension that has been removed. We'll + // show a dummy menuitem to warn the user. + let needCustomLabel = true; + for (let i = 0; i < gCustomActions.length; i++) { + if (gCustomActions[i].id == filterActionStr) { + needCustomLabel = false; + break; + } + } + if (needCustomLabel) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute( + "label", + gFilterBundle.getString("filterMissingCustomAction") + ); + menuitem.setAttribute("value", filterActionStr); + menuitem.disabled = true; + this.mRuleActionType.menupopup.appendChild(menuitem); + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + "Missing custom action " + filterActionStr, + null, + null, + 0, + 0, + Ci.nsIScriptError.errorFlag, + "component javascript" + ); + Services.console.logMessage(scriptError); + } + break; + case nsMsgFilterAction.MoveToFolder: + case nsMsgFilterAction.CopyToFolder: + actionItem.children[0].value = aFilterAction.targetFolderUri; + break; + case nsMsgFilterAction.Reply: + case nsMsgFilterAction.Forward: + actionItem.children[0].value = aFilterAction.strValue; + break; + case nsMsgFilterAction.ChangePriority: + actionItem.children[0].value = aFilterAction.priority; + break; + case nsMsgFilterAction.JunkScore: + actionItem.children[0].value = aFilterAction.junkScore; + break; + case nsMsgFilterAction.AddTag: + actionItem.children[0].value = aFilterAction.strValue; + break; + default: + break; + } + if (aFilterAction.type != nsMsgFilterAction.Custom) { + filterActionStr = gFilterActionStrings[aFilterAction.type]; + } + this.mRuleActionType.value = filterActionStr; + this.mRuleActionTargetInitialized = true; + this.clearInitialActionIndex(); + checkActionsReorder(); + } + + /** + * Function is used to check if the filter is valid or not. This routine + * also prompts the user. + * + * @returns {boolean} - true if this row represents a valid filter action. + */ + validateAction() { + let filterActionString = this.getAttribute("value"); + let actionTarget = this.children[1]; + let actionTargetLabel = + actionTarget.ruleactiontargetElement && + actionTarget.ruleactiontargetElement.children[0].value; + let errorString, customError; + + switch (filterActionString) { + case "movemessage": + case "copymessage": + let msgFolder = actionTargetLabel + ? MailUtils.getOrCreateFolder(actionTargetLabel) + : null; + if (!msgFolder || !msgFolder.canFileMessages) { + errorString = "mustSelectFolder"; + } + break; + case "forwardmessage": + if ( + actionTargetLabel.length < 3 || + actionTargetLabel.indexOf("@") < 1 + ) { + errorString = "enterValidEmailAddress"; + } + break; + case "replytomessage": + if (!actionTarget.ruleactiontargetElement.children[0].selectedItem) { + errorString = "pickTemplateToReplyWith"; + } + break; + default: + // Locate the correct custom action, and check validity. + for (let i = 0; i < gCustomActions.length; i++) { + if (gCustomActions[i].id == filterActionString) { + customError = gCustomActions[i].validateActionValue( + actionTargetLabel, + gFilterList.folder, + gFilterType + ); + break; + } + } + break; + } + + errorString = errorString + ? gFilterBundle.getString(errorString) + : customError; + if (errorString) { + Services.prompt.alert(window, null, errorString); + } + + return !errorString; + } + + /** + * Create a new filter action, fill it in, and then append it to the filter. + * + * @param {object} aFilter - filter object to save. + */ + saveToFilter(aFilter) { + let filterAction = aFilter.createAction(); + let filterActionString = this.getAttribute("value"); + filterAction.type = gFilterActionStrings.indexOf(filterActionString); + let actionTarget = this.children[1]; + let actionItem = actionTarget.ruleactiontargetElement; + let nsMsgFilterAction = Ci.nsMsgFilterAction; + switch (filterAction.type) { + case nsMsgFilterAction.ChangePriority: + filterAction.priority = actionItem.children[0].getAttribute("value"); + break; + case nsMsgFilterAction.MoveToFolder: + case nsMsgFilterAction.CopyToFolder: + filterAction.targetFolderUri = actionItem.children[0].value; + break; + case nsMsgFilterAction.JunkScore: + filterAction.junkScore = actionItem.children[0].value; + break; + case nsMsgFilterAction.Custom: + filterAction.customId = filterActionString; + // Fall through to set the value. + default: + if (actionItem && actionItem.children.length > 0) { + filterAction.strValue = actionItem.children[0].value; + } + break; + } + aFilter.appendAction(filterAction); + } + + /** + * If we only have one row of actions, then disable the remove button for that row. + */ + updateRemoveButton() { + this.mListBox.getItemAtIndex(0).mRemoveButton.disabled = + this.mListBox.getRowCount() == 1; + } + + addRow() { + let listItem = document.createXULElement("richlistitem", { + is: "ruleaction-richlistitem", + }); + listItem.classList.add("ruleaction"); + listItem.setAttribute("onfocus", "this.storeFocus();"); + this.mListBox.insertBefore(listItem, this.nextElementSibling); + this.mListBox.ensureElementIsVisible(listItem); + + // Make sure the first remove button is enabled. + this.updateRemoveButton(); + checkActionsReorder(); + } + + removeRow() { + // this.mListBox will fail after the row is removed, so save a reference. + let listBox = this.mListBox; + if (listBox.getRowCount() > 1) { + this.remove(); + } + // Can't use 'this' as it is destroyed now. + listBox.getItemAtIndex(0).updateRemoveButton(); + checkActionsReorder(); + } + + /** + * When this action row is focused, store its index in the parent richlistbox. + */ + storeFocus() { + this.mListBox.setAttribute( + "focusedAction", + this.mListBox.getIndexOfItem(this) + ); + } + } + + customElements.define("ruleaction-richlistitem", MozRuleactionRichlistitem, { + extends: "richlistitem", + }); +} diff --git a/comm/mailnews/search/content/viewLog.js b/comm/mailnews/search/content/viewLog.js new file mode 100644 index 0000000000..6ac120b2cf --- /dev/null +++ b/comm/mailnews/search/content/viewLog.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); + +var gFilterList; +var gLogFilters; +var gLogView; + +window.addEventListener("DOMContentLoaded", onLoad); + +function onLoad() { + gFilterList = window.arguments[0].filterList; + + gLogFilters = document.getElementById("logFilters"); + gLogFilters.checked = gFilterList.loggingEnabled; + + gLogView = document.getElementById("logView"); + + // for security, disable JS + gLogView.browsingContext.allowJavascript = false; + + MailE10SUtils.loadURI(gLogView, gFilterList.logURL); +} + +function toggleLogFilters() { + gFilterList.loggingEnabled = gLogFilters.checked; +} + +function clearLog() { + gFilterList.clearLog(); + + // reload the newly truncated file + gLogView.reload(); +} diff --git a/comm/mailnews/search/content/viewLog.xhtml b/comm/mailnews/search/content/viewLog.xhtml new file mode 100644 index 0000000000..66d2671df9 --- /dev/null +++ b/comm/mailnews/search/content/viewLog.xhtml @@ -0,0 +1,65 @@ + + + + + + + + + + &viewLog.title; + + + + + + &viewLogInfo.text; + + + + + + -- cgit v1.2.3