Skip to content

Commit

Permalink
Merge cd2737b into e57d7e5
Browse files Browse the repository at this point in the history
  • Loading branch information
nvdaes authored Jun 23, 2024
2 parents e57d7e5 + cd2737b commit f54b366
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 5 deletions.
34 changes: 33 additions & 1 deletion source/addonStore/dataManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def terminate():
class _DataManager:
_cacheLatestFilename: str = "_cachedLatestAddons.json"
_cacheCompatibleFilename: str = "_cachedCompatibleAddons.json"
_cacheCompatibleOldFilename: str = "_cachedCompatibleAddons-old.json"
_downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set()
_downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set()

Expand All @@ -87,6 +88,9 @@ def __init__(self):
self._preferredChannel = Channel.ALL
self._cacheLatestFile = os.path.join(WritePaths.addonStoreDir, _DataManager._cacheLatestFilename)
self._cacheCompatibleFile = os.path.join(WritePaths.addonStoreDir, _DataManager._cacheCompatibleFilename)
self._cacheCompatibleOldFile = os.path.join(
WritePaths.addonStoreDir, _DataManager._cacheCompatibleOldFilename
)
self._installedAddonDataCacheDir = WritePaths.addonsDir

if NVDAState.shouldWriteToDisk():
Expand All @@ -96,6 +100,7 @@ def __init__(self):

self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile)
self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile)
self._oldAddonCache = self._getCachedAddonData(self._cacheCompatibleOldFile)
self._installedAddonsCache = _InstalledAddonsCache()
# Fetch available add-ons cache early
self._initialiseAvailableAddonsThread = threading.Thread(
Expand All @@ -110,6 +115,7 @@ def terminate(self):
self._initialiseAvailableAddonsThread.join(timeout=1)
if self._initialiseAvailableAddonsThread.is_alive():
log.debugWarning("initialiseAvailableAddons thread did not terminate immediately")
self._cacheCompatibleAddonsBackup()

def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion)
Expand Down Expand Up @@ -158,6 +164,17 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]):
with open(self._cacheCompatibleFile, 'w', encoding='utf-8') as cacheFile:
json.dump(cacheData, cacheFile, ensure_ascii=False)

def _cacheCompatibleAddonsBackup(self):
if not NVDAState.shouldWriteToDisk():
return
try:
with open(self._cacheCompatibleFile, 'r', encoding='utf-8') as cacheFile:
cacheData = json.load(cacheFile)
except Exception:
log.exception("Invalid add-on store cache")
with open(self._cacheCompatibleOldFile, 'w', encoding='utf-8') as cacheFile:
json.dump(cacheData, cacheFile, ensure_ascii=False)

def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]):
if not NVDAState.shouldWriteToDisk():
return
Expand All @@ -179,7 +196,7 @@ def _getCachedAddonData(self, cacheFilePath: str) -> Optional[CachedAddonsModel]
with open(cacheFilePath, 'r', encoding='utf-8') as cacheFile:
cacheData = json.load(cacheFile)
except Exception:
log.exception(f"Invalid add-on store cache")
log.exception("Invalid add-on store cache")
if NVDAState.shouldWriteToDisk():
os.remove(cacheFilePath)
return None
Expand Down Expand Up @@ -281,6 +298,21 @@ def getLatestAddons(
return _createAddonGUICollection()
return deepcopy(self._latestAddonCache.cachedAddonData)

def _checkForNewAddons(self) -> bool:
oldAddons = self._getOldAddons()
compatibleAddons = self.getLatestCompatibleAddons()
for channel in compatibleAddons:
for addonId in compatibleAddons[channel]:
if addonId not in oldAddons[channel]:
return True
return False

def _getOldAddons(self) -> "AddonGUICollectionT":
if self._oldAddonCache is None:
return _createAddonGUICollection()
oldAddons = deepcopy(self._oldAddonCache.cachedAddonData)
return oldAddons

def _deleteCacheInstalledAddon(self, addonId: str):
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json")
if pathlib.Path(addonCachePath).exists():
Expand Down
12 changes: 11 additions & 1 deletion source/addonStore/models/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ class _StatusFilterKey(DisplayStringEnum):
UPDATE = enum.auto()
AVAILABLE = enum.auto()
INCOMPATIBLE = enum.auto()
NEW = enum.auto()

@property
def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]:
Expand All @@ -173,6 +174,9 @@ def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]:
# Translators: The label of a tab to display incompatible add-ons in the add-on store.
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible add-ons"),
# Translators: The label of a tab to display new add-ons in the add-on store.
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
self.NEW: pgettext("addonStore", "Available new add-ons"),
}

@property
Expand All @@ -194,6 +198,10 @@ def _displayStringLabelsWithAccelerators(self) -> Dict["_StatusFilterKey", str]:
# Preferably use the same accelerator key for the four labels.
# Ensure the translation matches the label for the add-on tab which has the accelerator key removed.
self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible &add-ons"),
# Translators: The label of the add-ons list in the corresponding panel.
# Preferably use the same accelerator key for the four labels.
# Ensure the translation matches the label for the add-on tab which has the accelerator key removed.
self.NEW: pgettext("addonStore", "Available new &add-ons"),
}

@property
Expand Down Expand Up @@ -313,7 +321,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
:return: Status of add-on for the context of the current tab.
"""

if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE):
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW):
downloadableStatus = _getDownloadableStatus(model)
if downloadableStatus:
# Is this available in the add-on store and not installed?
Expand Down Expand Up @@ -417,6 +425,8 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
AvailableAddonStatus.INCOMPATIBLE_ENABLED,
AvailableAddonStatus.UNKNOWN,
},
_StatusFilterKey.NEW: _installingStatuses
.union({AvailableAddonStatus.AVAILABLE})
})
"""A dictionary where the keys are a status to filter by,
and the values are which statuses should be shown for a given filter.
Expand Down
15 changes: 15 additions & 0 deletions source/config/configFlags.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,18 @@ def _displayStringLabels(self):
# Translators: This is a label for the automatic update behaviour for add-ons.
self.DISABLED: _("Disabled"),
}


class ShowNewAddons(DisplayStringStrEnum):
NOTIFY = "notify"
DISABLED = "disabled"

@property
def _displayStringLabels(self):
return {
# Translators: This is a label for the show new add-ons at startup behavior.
# It will notify the user when new add-ons are available.
self.NOTIFY: _("Notify"),
# Translators: This is a label for the show new add-ons at startup behavior.
self.DISABLED: _("Disabled"),
}
1 change: 1 addition & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@
[addonStore]
showWarning = boolean(default=true)
automaticUpdates = option("notify", "disabled", default="notify")
showNewAddons = option("notify", "disabled", default="notify")
"""

#: The configuration specification
Expand Down
15 changes: 15 additions & 0 deletions source/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,21 @@ def onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent | None):
_storeVM.refresh()
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.UPDATE)

@blockAction.when(
blockAction.Context.SECURE_MODE,
blockAction.Context.MODAL_DIALOG_OPEN,
blockAction.Context.WINDOWS_LOCKED,
blockAction.Context.WINDOWS_STORE_VERSION,
blockAction.Context.RUNNING_LAUNCHER,
)
def onAddonStoreNewAddonsCommand(self, evt: wx.MenuEvent | None):
from .addonStoreGui import AddonStoreDialog
from .addonStoreGui.viewModels.store import AddonStoreVM
from addonStore.models.status import _StatusFilterKey
_storeVM = AddonStoreVM()
_storeVM.refresh()
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.NEW)

def onReloadPluginsCommand(self, evt):
import appModuleHandler, globalPluginHandler
from NVDAObjects import NVDAObject
Expand Down
20 changes: 20 additions & 0 deletions source/gui/addonStoreGui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

import wx

from addonStore.dataManager import addonDataManager
import config
from config.configFlags import ShowNewAddons
import gui
from utils.schedule import scheduleThread, ThreadTarget

from .controls.storeDialog import AddonStoreDialog
Expand All @@ -19,3 +25,17 @@ def initialize():
UpdatableAddonsDialog._checkForUpdatableAddons,
queueToThread=ThreadTarget.GUI,
)
scheduleThread.scheduleDailyJobAtStartUp(
showNewAddons,
queueToThread=ThreadTarget.GUI,
)


def showNewAddons():
if (
ShowNewAddons.NOTIFY == config.conf["addonStore"]["showNewAddons"]
and addonDataManager._oldAddonCache is not None
):
availableNewAddons = addonDataManager._checkForNewAddons()
if availableNewAddons:
wx.CallAfter(gui.mainFrame.onAddonStoreNewAddonsCommand, None)
4 changes: 4 additions & 0 deletions source/gui/addonStoreGui/controls/storeDialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ def _toggleFilterControls(self):
if self._storeVM._filteredStatusKey in {
_StatusFilterKey.AVAILABLE,
_StatusFilterKey.UPDATE,
_StatusFilterKey.NEW,
}:
if self._storeVM._filteredStatusKey == _StatusFilterKey.UPDATE and (
self._storeVM._installedAddons[Channel.DEV]
Expand All @@ -333,6 +334,9 @@ def _toggleFilterControls(self):
self.enabledFilterCtrl.Disable()
self.includeIncompatibleCtrl.Enable()
self.includeIncompatibleCtrl.Show()
if self._storeVM._filteredStatusKey != _StatusFilterKey.NEW:
self.includeIncompatibleCtrl.Disable()
self.includeIncompatibleCtrl.Hide()
else:
self.channelFilterCtrl.Append(Channel.EXTERNAL.displayString)
self._storeVM._filterChannelKey = Channel.ALL
Expand Down
4 changes: 2 additions & 2 deletions source/gui/addonStoreGui/viewModels/addonList.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class AddonListField(_AddonListFieldData, Enum):
# Translators: The name of the column that contains the installed addon's version string.
pgettext("addonStore", "Installed version"),
100,
frozenset({_StatusFilterKey.AVAILABLE}),
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.NEW}),
)
availableAddonVersionName = (
# Translators: The name of the column that contains the available addon's version string.
Expand All @@ -88,7 +88,7 @@ class AddonListField(_AddonListFieldData, Enum):
# Translators: The name of the column that contains the addon's author.
pgettext("addonStore", "Author"),
100,
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE})
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW})
)


Expand Down
13 changes: 13 additions & 0 deletions source/gui/addonStoreGui/viewModels/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ def refresh(self):
if self._filteredStatusKey in {
_StatusFilterKey.AVAILABLE,
_StatusFilterKey.UPDATE,
_StatusFilterKey.NEW,
}:
self._refreshAddonsThread = threading.Thread(
target=self._getAvailableAddonsInBG,
Expand Down Expand Up @@ -603,6 +604,17 @@ def _getAvailableAddonsInBG(self):
and incompatibleAddons[channel][addonId].canOverrideCompatibility
):
availableAddons[channel][addonId] = incompatibleAddons[channel][addonId]
elif self._filteredStatusKey == _StatusFilterKey.NEW:
oldAddons = addonDataManager._getOldAddons()
newAddons = _createAddonGUICollection()
for channel in availableAddons:
for addonId in availableAddons[channel]:
if (
addonId not in oldAddons[channel]
or availableAddons[channel][addonId].addonVersionNumber != oldAddons[channel][addonId].addonVersionNumber
):
newAddons[channel][addonId] = availableAddons[channel][addonId]
availableAddons = newAddons
log.debug("completed getting addons in the background")
self._availableAddons = availableAddons
self.listVM.resetListItems(self._createListItemVMs())
Expand Down Expand Up @@ -643,6 +655,7 @@ def _createListItemVMs(self) -> List[AddonListItemVM]:
if self._filteredStatusKey in {
_StatusFilterKey.AVAILABLE,
_StatusFilterKey.UPDATE,
_StatusFilterKey.NEW,
}:
addons = self._availableAddons

Expand Down
13 changes: 13 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import config
from config.configFlags import (
AddonsAutomaticUpdate,
ShowNewAddons,
NVDAKey,
ShowMessages,
TetherTo,
Expand Down Expand Up @@ -2911,10 +2912,22 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None:
self.bindHelpEvent("AutomaticAddonUpdates", self.automaticUpdatesComboBox)
index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"])
self.automaticUpdatesComboBox.SetSelection(index)
# Translators: This is a label for the show new add-ons combo box in the Add-on Store Settings dialog.
showNewAddonsLabelText = _("&Show new add-ons:")
self.showNewAddonsComboBox = sHelper.addLabeledControl(
showNewAddonsLabelText,
wx.Choice,
choices=[mode.displayString for mode in AddonsAutomaticUpdate]
)
self.bindHelpEvent("ShowNewAddons", self.showNewAddonsComboBox)
index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["showNewAddons"])
self.showNewAddonsComboBox.SetSelection(index)

def onSave(self):
index = self.automaticUpdatesComboBox.GetSelection()
config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index]
index = self.showNewAddonsComboBox.GetSelection()
config.conf["addonStore"]["showNewAddons"] = [x.value for x in ShowNewAddons][index]


class TouchInteractionPanel(SettingsPanel):
Expand Down
18 changes: 18 additions & 0 deletions tests/manual/addonStore.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,23 @@ For example: "Clock".

Full automatic updating is not currently supported.

### Check for new add-ons

1. Remove `source/userConfig/addonStore/_cachedCompatibleAddons-old.json`, or edit this file removing some entries corresponding to cached add-ons, or changing version number
1. Start NVDA
1. Open the store to the New available add-ons tab
1. Ensure Show new add-ons notifications are enabled in the Add-on Store panel
1. Trigger the Show new add-ons notification manually, or alternatively wait for the notification to occur
1. From the NVDA Python console, find the scheduled thread
```py
import schedule
schedule.jobs
```
1. Replace `i` with the index of the scheduled thread to find the job
```py
schedule.jobs[i].run()
```

## Other add-on actions

### Disabling an add-on
Expand Down Expand Up @@ -283,3 +300,4 @@ Where you can find your NVDA user configuration folder:
- For installed copies: `%APPDATA%\nvda`
- For source copies: `source\userConfig`
- Inside a portable copy directory: `userConfig`

3 changes: 3 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Unicode CLDR has also been updated.
* This can be disabled in the "Add-on Store" category of settings.
* NVDA checks daily for add-on updates.
* Only updates within the same channel will be checked (e.g. installed beta add-ons will only notify for updates in the beta channel).
* By default, after NVDA startup, you will be notified if any new add-on is available. (#16681, @nvdaes)
* This can be disabled in the "Add-on Store" category of settings.
* You can also check for new add-ons from the New available add-ons of the store.

### Changes

Expand Down
17 changes: 16 additions & 1 deletion user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ During the installation process, add-ons may display dialogs that you will need

#### Managing installed add-ons {#ManagingInstalledAddons}
Press `control+tab` to move between the tabs of the Add-on Store.
The tabs include: "Installed add-ons", "Updatable add-ons", "Available add-ons" and "Installed incompatible add-ons".
The tabs include: "Installed add-ons", "Updatable add-ons", "Available add-ons", "Installed incompatible add-ons" and "Available new add-ons".
Each of the tabs are set out similar to each other, as a list of add-ons, a panel for more details on the selected add-on, and a button to perform actions for the selected add-on.
The actions menu of installed add-ons includes "Disable" and "Remove" rather than "Install".
Disabling an add-on stops NVDA from loading it, but leaves it installed.
Expand Down Expand Up @@ -2946,6 +2946,21 @@ For example, for installed beta add-ons, you will only be notified of updates wi
|Enabled |Notify when updates are available to add-ons within the same channel |
|Disabled |Do not automatically check for updates to add-ons |

##### Show New Add-ons {#ShowNewAddons}

When this option is set to "Notify", the Add-on Store will notify you after NVDA startup if new add-ons were made available from the store since the last time you exitted NVDA.
You can check this at any moment moving to the Available new add-ons tab of the store.

| . {.hideHeaderRow} |.|
|---|---|
|Options |Notify (Default), Disabled |
|Default |Notify |

|Option |Behaviour |
|---|---|
|Enabled |Notify when new add-ons are available |
|Disabled |Do not automatically check for new add-ons |

#### Windows OCR Settings {#Win10OcrSettings}

The settings in this category allow you to configure [Windows OCR](#Win10Ocr).
Expand Down

0 comments on commit f54b366

Please sign in to comment.