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.