Skip to content

Commit b971048

Browse files
authored
Merge 4431cf4 into 405f9bf
2 parents 405f9bf + 4431cf4 commit b971048

30 files changed

Lines changed: 9475 additions & 9234 deletions

source/addonStore/dataManager.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import pathlib
1010
import threading
11+
from datetime import datetime
1112
from typing import (
1213
TYPE_CHECKING,
1314
Optional,
@@ -79,6 +80,7 @@ def terminate():
7980
class _DataManager:
8081
_cacheLatestFilename: str = "_cachedLatestAddons.json"
8182
_cacheCompatibleFilename: str = "_cachedCompatibleAddons.json"
83+
_cacheCompatibleOldFilename: str = "_cachedCompatibleAddons-old.json"
8284
_downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set()
8385
_downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set()
8486

@@ -87,6 +89,9 @@ def __init__(self):
8789
self._preferredChannel = Channel.ALL
8890
self._cacheLatestFile = os.path.join(WritePaths.addonStoreDir, _DataManager._cacheLatestFilename)
8991
self._cacheCompatibleFile = os.path.join(WritePaths.addonStoreDir, _DataManager._cacheCompatibleFilename)
92+
self._cacheCompatibleOldFile = os.path.join(
93+
WritePaths.addonStoreDir, _DataManager._cacheCompatibleOldFilename
94+
)
9095
self._installedAddonDataCacheDir = WritePaths.addonsDir
9196

9297
if NVDAState.shouldWriteToDisk():
@@ -96,6 +101,7 @@ def __init__(self):
96101

97102
self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile)
98103
self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile)
104+
self._oldAddonCache = self._getCachedAddonData(self._cacheCompatibleOldFile)
99105
self._installedAddonsCache = _InstalledAddonsCache()
100106
# Fetch available add-ons cache early
101107
self._initialiseAvailableAddonsThread = threading.Thread(
@@ -110,6 +116,8 @@ def terminate(self):
110116
self._initialiseAvailableAddonsThread.join(timeout=1)
111117
if self._initialiseAvailableAddonsThread.is_alive():
112118
log.debugWarning("initialiseAvailableAddons thread did not terminate immediately")
119+
if self._shouldCacheCompatibleAddonsBackup():
120+
self._cacheCompatibleAddonsBackup()
113121

114122
def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
115123
url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion)
@@ -158,6 +166,33 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]):
158166
with open(self._cacheCompatibleFile, 'w', encoding='utf-8') as cacheFile:
159167
json.dump(cacheData, cacheFile, ensure_ascii=False)
160168

169+
def _shouldCacheCompatibleAddonsBackup(self) -> bool:
170+
if not os.path.exists(self._cacheCompatibleOldFile):
171+
return True
172+
resetNewAddons = config.conf["addonStore"]["resetNewAddons"]
173+
if resetNewAddons == "startup":
174+
return True
175+
lastBackupTime = os.path.getmtime(self._cacheCompatibleOldFile)
176+
lastBackupDate = datetime.fromtimestamp(lastBackupTime)
177+
nowDate = datetime.now()
178+
diffDate = nowDate - lastBackupDate
179+
if resetNewAddons == "weekly" and diffDate.days >= 7:
180+
return True
181+
if resetNewAddons == "monthly" and diffDate.days >= 30:
182+
return True
183+
return False
184+
185+
def _cacheCompatibleAddonsBackup(self):
186+
if not NVDAState.shouldWriteToDisk():
187+
return
188+
try:
189+
with open(self._cacheCompatibleFile, 'r', encoding='utf-8') as cacheFile:
190+
cacheData = json.load(cacheFile)
191+
except Exception:
192+
log.exception("Invalid add-on store cache")
193+
with open(self._cacheCompatibleOldFile, 'w', encoding='utf-8') as cacheFile:
194+
json.dump(cacheData, cacheFile, ensure_ascii=False)
195+
161196
def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]):
162197
if not NVDAState.shouldWriteToDisk():
163198
return
@@ -281,6 +316,26 @@ def getLatestAddons(
281316
return _createAddonGUICollection()
282317
return deepcopy(self._latestAddonCache.cachedAddonData)
283318

319+
def _checkForNewAddons(self) -> bool:
320+
oldAddons = self._getOldAddons()
321+
compatibleAddons = self.getLatestCompatibleAddons()
322+
installedAddons = self._installedAddonsCache._get_installedAddons()
323+
for channel in compatibleAddons:
324+
for addonId in compatibleAddons[channel]:
325+
compatibleAddon = compatibleAddons[channel][addonId]
326+
if (
327+
addonId not in oldAddons[channel]
328+
or compatibleAddon.addonVersionNumber != oldAddons[channel][addonId].addonVersionNumber
329+
) and addonId not in installedAddons:
330+
return True
331+
return False
332+
333+
def _getOldAddons(self) -> "AddonGUICollectionT":
334+
if self._oldAddonCache is None:
335+
return _createAddonGUICollection()
336+
oldAddons = deepcopy(self._oldAddonCache.cachedAddonData)
337+
return oldAddons
338+
284339
def _deleteCacheInstalledAddon(self, addonId: str):
285340
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json")
286341
if pathlib.Path(addonCachePath).exists():

source/addonStore/models/status.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class _StatusFilterKey(DisplayStringEnum):
157157
UPDATE = enum.auto()
158158
AVAILABLE = enum.auto()
159159
INCOMPATIBLE = enum.auto()
160+
NEW = enum.auto()
160161

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

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

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

316-
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE):
324+
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW):
317325
downloadableStatus = _getDownloadableStatus(model)
318326
if downloadableStatus:
319327
# Is this available in the add-on store and not installed?
@@ -417,6 +425,8 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
417425
AvailableAddonStatus.INCOMPATIBLE_ENABLED,
418426
AvailableAddonStatus.UNKNOWN,
419427
},
428+
_StatusFilterKey.NEW: _installingStatuses
429+
.union({AvailableAddonStatus.AVAILABLE})
420430
})
421431
"""A dictionary where the keys are a status to filter by,
422432
and the values are which statuses should be shown for a given filter.

source/config/configFlags.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,38 @@ def _displayStringLabels(self):
220220
# Translators: This is a label for the automatic update behaviour for add-ons.
221221
self.DISABLED: _("Disabled"),
222222
}
223+
224+
225+
class ShowNewAddons(DisplayStringStrEnum):
226+
DISABLED = "disabled"
227+
NOTIFY = "notify"
228+
229+
@property
230+
def _displayStringLabels(self):
231+
return {
232+
# Translators: This is a label for the show new add-ons at startup behavior.
233+
self.DISABLED: _("Disabled"),
234+
# Translators: This is a label for the show new add-ons at startup behavior.
235+
# It will notify the user when new add-ons are available.
236+
self.NOTIFY: _("Notify"),
237+
}
238+
239+
240+
class ResetNewAddons(DisplayStringStrEnum):
241+
MONTHLY = "monthly"
242+
WEEKLY = "weekly"
243+
STARTUP = "startup"
244+
245+
@property
246+
def _displayStringLabels(self):
247+
return {
248+
# Translators: This is a label for the reset new add-ons behavior.
249+
# It will determine the period for an add-on to be considered as new.
250+
self.MONTHLY: _("Monthly"),
251+
# Translators: This is a label for the reset new add-ons behavior.
252+
# It will determine the period for an add-on to be considered as new.
253+
self.WEEKLY: _("Weekly"),
254+
# Translators: This is a label for the reset new add-ons behavior.
255+
# It will determine the period for an add-on to be considered as new.
256+
self.STARTUP: _("At startup"),
257+
}

source/config/configSpec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@
326326
[addonStore]
327327
showWarning = boolean(default=true)
328328
automaticUpdates = option("notify", "disabled", default="notify")
329+
showNewAddons = option("disabled", "notify", default="disabled")
330+
resetNewAddons = option("monthly", "weekly", "startup", default="monthly")
329331
"""
330332

331333
#: The configuration specification

source/gui/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,21 @@ def onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent | None):
423423
_storeVM.refresh()
424424
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.UPDATE)
425425

426+
@blockAction.when(
427+
blockAction.Context.SECURE_MODE,
428+
blockAction.Context.MODAL_DIALOG_OPEN,
429+
blockAction.Context.WINDOWS_LOCKED,
430+
blockAction.Context.WINDOWS_STORE_VERSION,
431+
blockAction.Context.RUNNING_LAUNCHER,
432+
)
433+
def onAddonStoreNewAddonsCommand(self, evt: wx.MenuEvent | None):
434+
from .addonStoreGui import AddonStoreDialog
435+
from .addonStoreGui.viewModels.store import AddonStoreVM
436+
from addonStore.models.status import _StatusFilterKey
437+
_storeVM = AddonStoreVM()
438+
_storeVM.refresh()
439+
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.NEW)
440+
426441
def onReloadPluginsCommand(self, evt):
427442
import appModuleHandler
428443
import globalPluginHandler

source/gui/addonStoreGui/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
# This file is covered by the GNU General Public License.
44
# See the file COPYING for more details.
55

6+
import wx
7+
8+
from addonStore.dataManager import addonDataManager
9+
import config
10+
from config.configFlags import ShowNewAddons
11+
import gui
612
from utils.schedule import scheduleThread, ThreadTarget
713

814
from .controls.storeDialog import AddonStoreDialog
@@ -19,3 +25,17 @@ def initialize():
1925
UpdatableAddonsDialog._checkForUpdatableAddons,
2026
queueToThread=ThreadTarget.GUI,
2127
)
28+
scheduleThread.scheduleDailyJobAtStartUp(
29+
showNewAddons,
30+
queueToThread=ThreadTarget.GUI,
31+
)
32+
33+
34+
def showNewAddons():
35+
if (
36+
ShowNewAddons.NOTIFY == config.conf["addonStore"]["showNewAddons"]
37+
and addonDataManager._oldAddonCache is not None
38+
):
39+
availableNewAddons = addonDataManager._checkForNewAddons()
40+
if availableNewAddons:
41+
wx.CallAfter(gui.mainFrame.onAddonStoreNewAddonsCommand, None)

source/gui/addonStoreGui/controls/storeDialog.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ def _toggleFilterControls(self):
321321
if self._storeVM._filteredStatusKey in {
322322
_StatusFilterKey.AVAILABLE,
323323
_StatusFilterKey.UPDATE,
324+
_StatusFilterKey.NEW,
324325
}:
325326
if self._storeVM._filteredStatusKey == _StatusFilterKey.UPDATE and (
326327
self._storeVM._installedAddons[Channel.DEV]
@@ -333,6 +334,9 @@ def _toggleFilterControls(self):
333334
self.enabledFilterCtrl.Disable()
334335
self.includeIncompatibleCtrl.Enable()
335336
self.includeIncompatibleCtrl.Show()
337+
if self._storeVM._filteredStatusKey != _StatusFilterKey.NEW:
338+
self.includeIncompatibleCtrl.Disable()
339+
self.includeIncompatibleCtrl.Hide()
336340
else:
337341
self.channelFilterCtrl.Append(Channel.EXTERNAL.displayString)
338342
self._storeVM._filterChannelKey = Channel.ALL

source/gui/addonStoreGui/viewModels/addonList.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class AddonListField(_AddonListFieldData, Enum):
6565
# Translators: The name of the column that contains the installed addon's version string.
6666
pgettext("addonStore", "Installed version"),
6767
100,
68-
frozenset({_StatusFilterKey.AVAILABLE}),
68+
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.NEW}),
6969
)
7070
availableAddonVersionName = (
7171
# Translators: The name of the column that contains the available addon's version string.
@@ -88,7 +88,7 @@ class AddonListField(_AddonListFieldData, Enum):
8888
# Translators: The name of the column that contains the addon's author.
8989
pgettext("addonStore", "Author"),
9090
100,
91-
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE})
91+
frozenset({_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW})
9292
)
9393

9494

source/gui/addonStoreGui/viewModels/store.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ def refresh(self):
566566
if self._filteredStatusKey in {
567567
_StatusFilterKey.AVAILABLE,
568568
_StatusFilterKey.UPDATE,
569+
_StatusFilterKey.NEW,
569570
}:
570571
self._refreshAddonsThread = threading.Thread(
571572
target=self._getAvailableAddonsInBG,
@@ -603,6 +604,18 @@ def _getAvailableAddonsInBG(self):
603604
and incompatibleAddons[channel][addonId].canOverrideCompatibility
604605
):
605606
availableAddons[channel][addonId] = incompatibleAddons[channel][addonId]
607+
elif self._filteredStatusKey == _StatusFilterKey.NEW:
608+
oldAddons = addonDataManager._getOldAddons()
609+
newAddons = _createAddonGUICollection()
610+
for channel in availableAddons:
611+
for addonId in availableAddons[channel]:
612+
availableAddon = availableAddons[channel][addonId]
613+
if (
614+
addonId not in oldAddons[channel]
615+
or availableAddon.addonVersionNumber != oldAddons[channel][addonId].addonVersionNumber
616+
):
617+
newAddons[channel][addonId] = availableAddons[channel][addonId]
618+
availableAddons = newAddons
606619
log.debug("completed getting addons in the background")
607620
self._availableAddons = availableAddons
608621
self.listVM.resetListItems(self._createListItemVMs())
@@ -643,6 +656,7 @@ def _createListItemVMs(self) -> List[AddonListItemVM]:
643656
if self._filteredStatusKey in {
644657
_StatusFilterKey.AVAILABLE,
645658
_StatusFilterKey.UPDATE,
659+
_StatusFilterKey.NEW,
646660
}:
647661
addons = self._availableAddons
648662

source/gui/settingsDialogs.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import config
2929
from config.configFlags import (
3030
AddonsAutomaticUpdate,
31+
ShowNewAddons,
32+
ResetNewAddons,
3133
NVDAKey,
3234
ShowMessages,
3335
TetherTo,
@@ -2910,10 +2912,34 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None:
29102912
self.bindHelpEvent("AutomaticAddonUpdates", self.automaticUpdatesComboBox)
29112913
index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"])
29122914
self.automaticUpdatesComboBox.SetSelection(index)
2915+
# Translators: This is a label for the show new add-ons combo box in the Add-on Store Settings dialog.
2916+
showNewAddonsLabelText = _("&Show new add-ons:")
2917+
self.showNewAddonsComboBox = sHelper.addLabeledControl(
2918+
showNewAddonsLabelText,
2919+
wx.Choice,
2920+
choices=[mode.displayString for mode in ShowNewAddons]
2921+
)
2922+
self.bindHelpEvent("ShowNewAddons", self.showNewAddonsComboBox)
2923+
index = [x.value for x in ShowNewAddons].index(config.conf["addonStore"]["showNewAddons"])
2924+
self.showNewAddonsComboBox.SetSelection(index)
2925+
# Translators: This is a label for the reset new add-ons combo box in the Add-on Store Settings dialog.
2926+
resetNewAddonsLabelText = _("&Reset new add-ons:")
2927+
self.resetNewAddonsComboBox = sHelper.addLabeledControl(
2928+
resetNewAddonsLabelText,
2929+
wx.Choice,
2930+
choices=[mode.displayString for mode in ResetNewAddons]
2931+
)
2932+
self.bindHelpEvent("ResetNewAddons", self.resetNewAddonsComboBox)
2933+
index = [x.value for x in ResetNewAddons].index(config.conf["addonStore"]["resetNewAddons"])
2934+
self.resetNewAddonsComboBox.SetSelection(index)
29132935

29142936
def onSave(self):
29152937
index = self.automaticUpdatesComboBox.GetSelection()
29162938
config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index]
2939+
index = self.showNewAddonsComboBox.GetSelection()
2940+
config.conf["addonStore"]["showNewAddons"] = [x.value for x in ShowNewAddons][index]
2941+
index = self.resetNewAddonsComboBox.GetSelection()
2942+
config.conf["addonStore"]["resetNewAddons"] = [x.value for x in ResetNewAddons][index]
29172943

29182944

29192945
class TouchInteractionPanel(SettingsPanel):

0 commit comments

Comments
 (0)