From 7a328bd82858b6ad59411e1534e18131346f3f6e Mon Sep 17 00:00:00 2001 From: Cyrille Bougot Date: Mon, 27 Nov 2023 05:40:49 +0100 Subject: [PATCH] Allow more batch actions in the add-on store (remove, enable and disable) (#15646) Closes #15623 Summary of the issue: In the store, install action can be executed on multiple add-ons. Other batch actions are needed. Description of user facing changes The following additional batch actions are now available in the store: Remove selected add-ons Update selected add-ons Enable selected add-ons Disable selected add-ons Description of development approach Use the same condition as the one required for the corresponding single action to occur. For code simplicity there is no distinction between Enable and Enable (override) for the corresponding multiple action. Anyway, when an add-on should override compatibility to be enabled, there is a confirmation dialog box. Also for simplicity, there is no multiple replace action: if a multiple selection contains installable add-on with or without updatable or replaceable add-ons, the "Install selected add-ons" batch action will be available If a multiple selection contains updatable add-ons, replaceable add-ons or both types, the "Update selected add-ons" batch action will be available. For batch actions requiring a confirmation (e.g. override incompatibility, uninstall), the confirmation dialog box has a "Remember this choice" checkbox so that the user can answer yes or no for all the same questions. When many add-ons are selected and a batch action is applied to them, the action actually applies only to the add-ons for which it is appropriate. E.g batch enable will not be applied to already enabled add-ons. --- source/gui/addonStoreGui/controls/actions.py | 86 ++++++- .../addonStoreGui/controls/messageDialogs.py | 99 +++++--- .../gui/addonStoreGui/viewModels/addonList.py | 36 +++ source/gui/addonStoreGui/viewModels/store.py | 232 ++++++++++++++---- user_docs/en/changes.t2t | 2 +- user_docs/en/userGuide.t2t | 4 +- 6 files changed, 378 insertions(+), 81 deletions(-) diff --git a/source/gui/addonStoreGui/controls/actions.py b/source/gui/addonStoreGui/controls/actions.py index 7ded65f74a9..ff3529820fe 100644 --- a/source/gui/addonStoreGui/controls/actions.py +++ b/source/gui/addonStoreGui/controls/actions.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited +# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -77,7 +77,7 @@ def _populateContextMenu(self): elif isMenuItemInContextMenu: # The action is invalid but the menu item exists and is in the context menu. # Remove the menu item from the context menu. - self._contextMenu.RemoveItem(menuItem) + self._contextMenu.Remove(menuItem) del self._actionMenuItemMap[action] menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) @@ -85,7 +85,7 @@ def _populateContextMenu(self): if menuItem not in self._actionMenuItemMap.values(): # The menu item is not in the action menu item map. # It should be removed from the context menu. - self._contextMenu.RemoveItem(menuItem) + self._contextMenu.Remove(menuItem) class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]): @@ -140,7 +140,85 @@ def _actions(self) -> List[BatchAddonActionVM]: # Translators: Label for an action that installs the selected add-ons displayName=pgettext("addonStore", "&Install selected add-ons"), actionHandler=self._storeVM.getAddons, - validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE, + validCheck=lambda aVMs: ( + self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE + and AddonListValidator(aVMs).canUseInstallAction() + ), + actionTarget=self._selectedAddons + ), + BatchAddonActionVM( + # Translators: Label for an action that updates the selected add-ons + displayName=pgettext("addonStore", "&Update selected add-ons"), + actionHandler=self._storeVM.getAddons, + validCheck=lambda aVMs: AddonListValidator(aVMs).canUseUpdateAction(), + actionTarget=self._selectedAddons + ), + BatchAddonActionVM( + # Translators: Label for an action that removes the selected add-ons + displayName=pgettext("addonStore", "&Remove selected add-ons"), + actionHandler=self._storeVM.removeAddons, + validCheck=lambda aVMs: ( + self._storeVM._filteredStatusKey in [ + # Removing add-ons in the updatable view fails, + # as the updated version cannot be removed. + _StatusFilterKey.INSTALLED, + _StatusFilterKey.INCOMPATIBLE, + ] + and AddonListValidator(aVMs).canUseRemoveAction() + ), + actionTarget=self._selectedAddons + ), + BatchAddonActionVM( + # Translators: Label for an action that enables the selected add-ons + displayName=pgettext("addonStore", "&Enable selected add-ons"), + actionHandler=self._storeVM.enableAddons, + validCheck=lambda aVMs: AddonListValidator(aVMs).canUseEnableAction(), + actionTarget=self._selectedAddons + ), + BatchAddonActionVM( + # Translators: Label for an action that disables the selected add-ons + displayName=pgettext("addonStore", "&Disable selected add-ons"), + actionHandler=self._storeVM.disableAddons, + validCheck=lambda aVMs: AddonListValidator(aVMs).canUseDisableAction(), actionTarget=self._selectedAddons ), ] + + +class AddonListValidator: + def __init__(self, addonsList: List[AddonListItemVM]): + self.addonsList = addonsList + + def canUseInstallAction(self) -> bool: + for aVM in self.addonsList: + if aVM.canUseInstallAction() or aVM.canUseInstallOverrideIncompatibilityAction(): + return True + return False + + def canUseUpdateAction(self) -> bool: + hasUpdatable = False + hasInstallable = False + for aVM in self.addonsList: + if aVM.canUseUpdateAction() or aVM.canUseReplaceAction(): + hasUpdatable = True + if aVM.canUseInstallAction() or aVM.canUseInstallOverrideIncompatibilityAction(): + hasInstallable = True + return hasUpdatable and not hasInstallable + + def canUseRemoveAction(self) -> bool: + for aVM in self.addonsList: + if aVM.canUseRemoveAction(): + return True + return False + + def canUseEnableAction(self) -> bool: + for aVM in self.addonsList: + if aVM.canUseEnableOverrideIncompatibilityAction() or aVM.canUseEnableAction(): + return True + return False + + def canUseDisableAction(self) -> bool: + for aVM in self.addonsList: + if aVM.canUseDisableAction(): + return True + return False diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 49bbc90cfc6..5398947d8fc 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023 NV Access Limited +# Copyright (C) 2023 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -32,6 +32,10 @@ class ErrorAddonInstallDialogWithYesNoButtons(ErrorAddonInstallDialog): + def __init__(self, *args, useRememberChoiceCheckbox: bool = False, **kwargs): + self.useRememberChoiceCheckbox = useRememberChoiceCheckbox + super().__init__(*args, **kwargs) + def _addButtons(self, buttonHelper: ButtonHelper) -> None: addonInfoButton = buttonHelper.addButton( self, @@ -58,11 +62,27 @@ def _addButtons(self, buttonHelper: ButtonHelper) -> None: noButton.SetDefault() noButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.NO)) + def _addContents(self, contentsSizer: BoxSizerHelper): + if self.useRememberChoiceCheckbox: + self.rememberChoiceCheckbox = wx.CheckBox( + self, + # Translators: A checkbox in the dialog to remember the choice made when installing or enabling + # incompatible add-ons, or when removing add-ons. + label=pgettext("addonStore", "Remember this choice for subsequent add-ons"), + ) + contentsSizer.addItem(self.rememberChoiceCheckbox) + + def shouldRememberChoice(self) -> bool: + if self.useRememberChoiceCheckbox: + return self.rememberChoiceCheckbox.IsChecked() + return False + def _shouldProceedWhenInstalledAddonVersionUnknown( parent: wx.Window, - addon: _AddonGUIModel -) -> bool: + addon: _AddonGUIModel, + useRememberChoiceCheckbox: bool = False, +) -> tuple[bool, bool]: # an installed add-on should have an addon Handler Model assert addon._addonHandlerModel incompatibleMessage = pgettext( @@ -81,37 +101,47 @@ def _shouldProceedWhenInstalledAddonVersionUnknown( lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) ) - res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons( + dlg = ErrorAddonInstallDialogWithYesNoButtons( parent=parent, # Translators: The title of a dialog presented when an error occurs. title=pgettext("addonStore", "Add-on not compatible"), message=incompatibleMessage, - showAddonInfoFunction=lambda: _showAddonInfo(addon) - )) - return res == wx.YES + showAddonInfoFunction=lambda: _showAddonInfo(addon), + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + res = displayDialogAsModal(dlg) + return (res == wx.YES), dlg.shouldRememberChoice() def _shouldProceedToRemoveAddonDialog( - addon: "SupportsVersionCheck" -) -> bool: - return messageBox( - pgettext( - "addonStore", - # Translators: Presented when attempting to remove the selected add-on. - # {addon} is replaced with the add-on name. - "Are you sure you wish to remove the {addon} add-on from NVDA? " - "This cannot be undone." - ).format(addon=addon.name), + parent, + addon: "SupportsVersionCheck", + useRememberChoiceCheckbox: bool = False, +) -> tuple[bool, bool]: + removeMessage = pgettext( + "addonStore", + # Translators: Presented when attempting to remove the selected add-on. + # {addon} is replaced with the add-on name. + "Are you sure you wish to remove the {addon} add-on from NVDA? " + "This cannot be undone." + ).format(addon=addon.name) + dlg = ErrorAddonInstallDialogWithYesNoButtons( + parent=parent, # Translators: Title for message asking if the user really wishes to remove the selected Add-on. - pgettext("addonStore", "Remove Add-on"), - wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING - ) == wx.YES + title=pgettext("addonStore", "Remove Add-on"), + message=removeMessage, + showAddonInfoFunction=lambda: _showAddonInfo(addon), + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + res = displayDialogAsModal(dlg) + return (res == wx.YES), dlg.shouldRememberChoice() def _shouldInstallWhenAddonTooOldDialog( parent: wx.Window, - addon: _AddonGUIModel -) -> bool: + addon: _AddonGUIModel, + useRememberChoiceCheckbox: bool = False, +) -> tuple[bool, bool]: incompatibleMessage = pgettext( "addonStore", # Translators: The message displayed when installing an add-on package that is incompatible @@ -128,20 +158,23 @@ def _shouldInstallWhenAddonTooOldDialog( lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) ) - res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons( + dlg = ErrorAddonInstallDialogWithYesNoButtons( parent=parent, # Translators: The title of a dialog presented when an error occurs. title=pgettext("addonStore", "Add-on not compatible"), message=incompatibleMessage, - showAddonInfoFunction=lambda: _showAddonInfo(addon) - )) - return res == wx.YES + showAddonInfoFunction=lambda: _showAddonInfo(addon), + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + res = displayDialogAsModal(dlg) + return (res == wx.YES), dlg.shouldRememberChoice() def _shouldEnableWhenAddonTooOldDialog( parent: wx.Window, - addon: _AddonGUIModel -) -> bool: + addon: _AddonGUIModel, + useRememberChoiceCheckbox: bool = False, +) -> tuple[bool, bool]: incompatibleMessage = pgettext( "addonStore", # Translators: The message displayed when enabling an add-on package that is incompatible @@ -158,14 +191,16 @@ def _shouldEnableWhenAddonTooOldDialog( lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) ) - res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons( + dlg = ErrorAddonInstallDialogWithYesNoButtons( parent=parent, # Translators: The title of a dialog presented when an error occurs. title=pgettext("addonStore", "Add-on not compatible"), message=incompatibleMessage, - showAddonInfoFunction=lambda: _showAddonInfo(addon) - )) - return res == wx.YES + showAddonInfoFunction=lambda: _showAddonInfo(addon), + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + res = displayDialogAsModal(dlg) + return (res == wx.YES), dlg.shouldRememberChoice() def _showAddonInfo(addon: _AddonGUIModel) -> None: diff --git a/source/gui/addonStoreGui/viewModels/addonList.py b/source/gui/addonStoreGui/viewModels/addonList.py index 5ddcbc89a82..3d73748e6bd 100644 --- a/source/gui/addonStoreGui/viewModels/addonList.py +++ b/source/gui/addonStoreGui/viewModels/addonList.py @@ -124,6 +124,42 @@ def status(self, newStatus: AvailableAddonStatus): def Id(self) -> str: return self._model.listItemVMId + def canUseInstallAction(self) -> bool: + return self.status == AvailableAddonStatus.AVAILABLE + + def canUseInstallOverrideIncompatibilityAction(self) -> bool: + return self.status == AvailableAddonStatus.INCOMPATIBLE and self.model.canOverrideCompatibility + + def canUseUpdateAction(self) -> bool: + return self.status == AvailableAddonStatus.UPDATE + + def canUseReplaceAction(self) -> bool: + return self.status == AvailableAddonStatus.REPLACE_SIDE_LOAD + + def canUseRemoveAction(self) -> bool: + return ( + self.model.isInstalled + and self.status != AvailableAddonStatus.PENDING_REMOVE + ) + + def canUseEnableAction(self) -> bool: + return self.status == AvailableAddonStatus.DISABLED or self.status == AvailableAddonStatus.PENDING_DISABLE + + def canUseEnableOverrideIncompatibilityAction(self) -> bool: + return self.status in ( + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + ) and self.model.canOverrideCompatibility + + def canUseDisableAction(self) -> bool: + return self.model.isInstalled and self.status not in ( + AvailableAddonStatus.DISABLED, + AvailableAddonStatus.PENDING_DISABLE, + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_REMOVE, + ) + def __repr__(self) -> str: return f"{self.__class__.__name__}: {self.Id}, {self.status}" diff --git a/source/gui/addonStoreGui/viewModels/store.py b/source/gui/addonStoreGui/viewModels/store.py index 7571eec8cf2..6fa2d7041e3 100644 --- a/source/gui/addonStoreGui/viewModels/store.py +++ b/source/gui/addonStoreGui/viewModels/store.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited +# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -115,24 +115,21 @@ def _makeActionsList(self): # Translators: Label for an action that installs the selected addon displayName=pgettext("addonStore", "&Install"), actionHandler=self.getAddon, - validCheck=lambda aVM: aVM.status == AvailableAddonStatus.AVAILABLE, + validCheck=lambda aVM: aVM.canUseInstallAction(), actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that installs the selected addon displayName=pgettext("addonStore", "&Install (override incompatibility)"), actionHandler=self.installOverrideIncompatibilityForAddon, - validCheck=lambda aVM: ( - aVM.status == AvailableAddonStatus.INCOMPATIBLE - and aVM.model.canOverrideCompatibility - ), + validCheck=lambda aVM: aVM.canUseInstallOverrideIncompatibilityAction(), actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that updates the selected addon displayName=pgettext("addonStore", "&Update"), actionHandler=self.getAddon, - validCheck=lambda aVM: aVM.status == AvailableAddonStatus.UPDATE, + validCheck=lambda aVM: aVM.canUseUpdateAction(), actionTarget=selectedListItem ), AddonActionVM( @@ -140,43 +137,28 @@ def _makeActionsList(self): # an add-on store version. displayName=pgettext("addonStore", "Re&place"), actionHandler=self.replaceAddon, - validCheck=lambda aVM: aVM.status == AvailableAddonStatus.REPLACE_SIDE_LOAD, + validCheck=lambda aVM: aVM.canUseReplaceAction(), actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that disables the selected addon displayName=pgettext("addonStore", "&Disable"), actionHandler=self.disableAddon, - validCheck=lambda aVM: aVM.model.isInstalled and aVM.status not in ( - AvailableAddonStatus.DISABLED, - AvailableAddonStatus.PENDING_DISABLE, - AvailableAddonStatus.INCOMPATIBLE_DISABLED, - AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, - AvailableAddonStatus.PENDING_REMOVE, - ), + validCheck=lambda aVM: aVM.canUseDisableAction(), actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon displayName=pgettext("addonStore", "&Enable"), actionHandler=self.enableAddon, - validCheck=lambda aVM: ( - aVM.status == AvailableAddonStatus.DISABLED - or aVM.status == AvailableAddonStatus.PENDING_DISABLE - ), + validCheck=lambda aVM: aVM.canUseEnableAction(), actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon displayName=pgettext("addonStore", "&Enable (override incompatibility)"), actionHandler=self.enableOverrideIncompatibilityForAddon, - validCheck=lambda aVM: ( - aVM.status in ( - AvailableAddonStatus.INCOMPATIBLE_DISABLED, - AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, - ) - and aVM.model.canOverrideCompatibility - ), + validCheck=lambda aVM: aVM.canUseEnableOverrideIncompatibilityAction(), actionTarget=selectedListItem ), AddonActionVM( @@ -184,8 +166,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "&Remove"), actionHandler=self.removeAddon, validCheck=lambda aVM: ( - aVM.model.isInstalled - and aVM.status != AvailableAddonStatus.PENDING_REMOVE + aVM.canUseRemoveAction() and self._filteredStatusKey in ( # Removing add-ons in the updatable view fails, # as the updated version cannot be removed. @@ -264,22 +245,75 @@ def helpAddon(self, listItemVM: AddonListItemVM) -> None: assert path is not None startfile(path) - def removeAddon(self, listItemVM: AddonListItemVM[_AddonGUIModel]) -> None: + def removeAddon( + self, + listItemVM: AddonListItemVM[_AddonGUIModel], + askConfirmation: bool = True, + useRememberChoiceCheckbox: bool = False, + ) -> tuple[bool, bool]: + from gui import mainFrame assert addonDataManager assert listItemVM.model - if _shouldProceedToRemoveAddonDialog(listItemVM.model): + if askConfirmation: + shouldRemove, shouldRememberChoice = _shouldProceedToRemoveAddonDialog( + mainFrame, + listItemVM.model, + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + else: + shouldRemove = True + shouldRememberChoice = True + if shouldRemove: addonDataManager._deleteCacheInstalledAddon(listItemVM.model.name) assert listItemVM.model._addonHandlerModel is not None listItemVM.model._addonHandlerModel.requestRemove() self.refresh() listItemVM.status = getStatus(listItemVM.model) + return shouldRemove, shouldRememberChoice + + def removeAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + shouldRemove = True + shouldRememberChoice = False + for aVM in listItemVMs: + if aVM.canUseRemoveAction(): + log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not relevant for remove action") + else: + if shouldRememberChoice: + if shouldRemove: + self.removeAddon(aVM, askConfirmation=False) + else: + log.debug( + f"Skipping {aVM.Id} as removal has been previously declined for all remaining" + " add-ons." + ) + else: + shouldRemove, shouldRememberChoice = self.removeAddon( + aVM, + askConfirmation=True, + useRememberChoiceCheckbox=True, + ) - def installOverrideIncompatibilityForAddon(self, listItemVM: AddonListItemVM) -> None: + def installOverrideIncompatibilityForAddon( + self, + listItemVM: AddonListItemVM, + askConfirmation: bool = True, + useRememberChoiceCheckbox: bool = False, + ) -> tuple[bool, bool]: from gui import mainFrame - if _shouldInstallWhenAddonTooOldDialog(mainFrame, listItemVM.model): + if askConfirmation: + shouldInstall, shouldRememberChoice = _shouldInstallWhenAddonTooOldDialog( + mainFrame, + listItemVM.model, + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + else: + shouldInstall = True + shouldRememberChoice = True + if shouldInstall: listItemVM.model.enableCompatibilityOverride() self.getAddon(listItemVM) self.refresh() + return shouldInstall, shouldRememberChoice _enableErrorMessage: str = pgettext( "addonStore", @@ -313,22 +347,106 @@ def _handleEnableDisable(self, listItemVM: AddonListItemVM[_AddonManifestModel], listItemVM.status = getStatus(listItemVM.model) self.refresh() - def enableOverrideIncompatibilityForAddon(self, listItemVM: AddonListItemVM[_AddonManifestModel]) -> None: + def enableOverrideIncompatibilityForAddon( + self, + listItemVM: AddonListItemVM[_AddonManifestModel], + askConfirmation: bool = True, + useRememberChoiceCheckbox: bool = False, + ) -> tuple[bool, bool]: from ... import mainFrame - if _shouldEnableWhenAddonTooOldDialog(mainFrame, listItemVM.model): + if askConfirmation: + shouldEnable, shouldRememberChoice = _shouldEnableWhenAddonTooOldDialog( + mainFrame, + listItemVM.model, + + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + else: + shouldEnable = True + shouldRememberChoice = True + if shouldEnable: listItemVM.model.enableCompatibilityOverride() self._handleEnableDisable(listItemVM, True) + return shouldEnable, shouldRememberChoice def enableAddon(self, listItemVM: AddonListItemVM) -> None: self._handleEnableDisable(listItemVM, True) + def enableAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + shouldEnableIncompatible = True + shouldRememberChoice = False + for aVM in listItemVMs: + if aVM.canUseEnableOverrideIncompatibilityAction(): + if shouldRememberChoice: + if shouldEnableIncompatible: + self.enableOverrideIncompatibilityForAddon(aVM, askConfirmation=False) + else: + log.debug( + f"Skipping {aVM.Id} as override incompatibility has been previously declined for all remaining" + " add-ons." + ) + else: + shouldEnableIncompatible, shouldRememberChoice = self.enableOverrideIncompatibilityForAddon( + aVM, + askConfirmation=True, + useRememberChoiceCheckbox=True, + ) + elif aVM.canUseEnableAction(): + self.enableAddon(aVM) + else: + log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not relevant for enable action") + def disableAddon(self, listItemVM: AddonListItemVM) -> None: self._handleEnableDisable(listItemVM, False) - def replaceAddon(self, listItemVM: AddonListItemVM) -> None: + def disableAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + for aVM in listItemVMs: + if not aVM.canUseDisableAction(): + log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not relevant for disable action") + else: + self.disableAddon(aVM) + + def replaceAddon( + self, + listItemVM: AddonListItemVM, + askConfirmation: bool = True, + useRememberChoiceCheckbox: bool = False, + ) -> tuple[bool, bool]: from ... import mainFrame - if _shouldProceedWhenInstalledAddonVersionUnknown(mainFrame, listItemVM.model): + assert listItemVM.model + if askConfirmation: + shouldReplace, shouldRememberChoice = _shouldProceedWhenInstalledAddonVersionUnknown( + mainFrame, + listItemVM.model, + useRememberChoiceCheckbox=useRememberChoiceCheckbox, + ) + else: + shouldReplace = True + shouldRememberChoice = True + if shouldReplace: self.getAddon(listItemVM) + return shouldReplace, shouldRememberChoice + + def replaceAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + shouldReplace = True + shouldRememberChoice = False + for aVM in listItemVMs: + if not aVM.canUseReplaceAction(): + log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not relevant for replace action") + else: + if shouldRememberChoice: + if shouldReplace: + self.replaceAddon(aVM, askConfirmation=False) + else: + log.debug( + f"Skipping {aVM.Id} as replacement has been previously declined for all remaining add-ons." + ) + else: + shouldReplace, shouldRememberChoice = self.replaceAddon( + aVM, + askConfirmation=True, + useRememberChoiceCheckbox=True, + ) def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: assert addonDataManager @@ -338,16 +456,44 @@ def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + shouldReplace = True + shouldInstallIncompatible = True + shouldRememberReplaceChoice = False + shouldRememberInstallChoice = False for aVM in listItemVMs: - if aVM.status not in ( - AvailableAddonStatus.AVAILABLE, - AvailableAddonStatus.UPDATE, - ): - log.debug(f"Skipping {aVM.Id} as it is not available or updatable") + if aVM.canUseInstallAction() or aVM.canUseUpdateAction(): + self.getAddon(aVM) + elif aVM.canUseReplaceAction(): + if shouldRememberReplaceChoice: + if shouldReplace: + self.replaceAddon(aVM, askConfirmation=False) + else: + log.debug( + f"Skipping {aVM.Id} as replacement has been previously declined for all remaining add-ons." + ) + else: + shouldReplace, shouldRememberReplaceChoice = self.replaceAddon( + aVM, + askConfirmation=True, + useRememberChoiceCheckbox=True, + ) elif not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: - self.installOverrideIncompatibilityForAddon(aVM) + if shouldRememberInstallChoice: + if shouldInstallIncompatible: + self.installOverrideIncompatibilityForAddon(aVM, askConfirmation=False) + else: + log.debug( + f"Skipping {aVM.Id} as override incompatibility has been previously declined for all remaining" + " add-ons." + ) + else: + shouldInstallIncompatible, shouldRememberInstallChoice = self.installOverrideIncompatibilityForAddon( + aVM, + askConfirmation=True, + useRememberChoiceCheckbox=True + ) else: - self.getAddon(aVM) + log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not available or updatable") def _downloadComplete( self, diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 69ba9ceb6a6..93695207dfe 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -8,7 +8,7 @@ What's New in NVDA == New Features == - Added support for Bluetooth Low Energy HID Braille displays. (#15470) -- The Add-on Store now supports installing add-ons in bulk by selecting multiple add-ons. (#15350) +- The Add-on Store now supports bulk actions (e.g. installing, enabling add-ons) by selecting multiple add-ons. (#15350, #15623, @CyrilleB79) - From the add-on store, it's possible to open a dedicated webpage to see or provide feedback about the selected add-on. (#15576, @nvdaes) - A new "on-demand" speech mode has been added. When speech is on-demand, NVDA does not speak automatically (e.g. when moving the cursor) but still speaks when calling commands whose goal is explicitly to report something (e.g. report window title). (#481, @CyrilleB79) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 0ccf15c7592..e4ac9dc616d 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -2838,7 +2838,7 @@ Select an add-on from the "Available add-ons" or "Updatable add-ons" tab. Then use the update, install, or replace action to start the installation. You can also install multiple add-ons at once. -This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. +This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the "Install selected add-ons" action. To install an add-on you have obtained outside of the Add-on Store, press the "Install from external source" button. This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. @@ -2854,6 +2854,7 @@ To remove an add-on, select the add-on from the list and use the Remove action. NVDA will ask you to confirm removal. As with installing, NVDA must be restarted for the add-on to be fully removed. Until you do, a status of "Pending removal" will be shown for that add-on in the list. +As with installing, you can also remove multiple add-ons at once. +++ Disabling and Enabling Add-ons +++[AddonStoreDisablingEnabling] To disable an add-on, use the "disable" action. @@ -2863,6 +2864,7 @@ For each use of the enable/disable action, add-on status changes to indicate wha If the add-on was previously "disabled", the status will show "enabled after restart". If the add-on was previously "enabled", the status will show "disabled after restart". Just like when you install or remove add-ons, you need to restart NVDA in order for changes to take effect. +You can also enable or disable multiple add-ons at once by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the appropriate action. +++ Reviewing add-ons and reading reviews +++[AddonStoreReviews] Before installing an add-on, you may want to read reviews by others.