Skip to content

Commit 11866d4

Browse files
authored
Merge 9e53cd0 into 62c9122
2 parents 62c9122 + 9e53cd0 commit 11866d4

13 files changed

Lines changed: 269 additions & 4 deletions

File tree

source/addonStore/dataManager.py

Lines changed: 70 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, timedelta
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,48 @@ 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 _getResetNewAddonsDate(self) -> str:
186+
resetNewAddons = config.conf["addonStore"]["resetNewAddons"]
187+
if resetNewAddons == "startup":
188+
return _("Will be reset at startup")
189+
lastBackupTime = os.path.getmtime(self._cacheCompatibleOldFile)
190+
lastBackupDate = datetime.fromtimestamp(lastBackupTime)
191+
formattedLastBackupDate = lastBackupDate.strftime("%d/%m/%Y")
192+
if resetNewAddons == "monthly":
193+
timedeltaDays = 30
194+
else: # weekly
195+
timedeltaDays = 7
196+
nextResetDate = lastBackupDate+timedelta(days=timedeltaDays)
197+
formattedNextResetDate = nextResetDate.strftime("%d/%m/%Y")
198+
return f"{formattedLastBackupDate}-{formattedNextResetDate}"
199+
200+
def _cacheCompatibleAddonsBackup(self):
201+
if not NVDAState.shouldWriteToDisk():
202+
return
203+
try:
204+
with open(self._cacheCompatibleFile, 'r', encoding='utf-8') as cacheFile:
205+
cacheData = json.load(cacheFile)
206+
except Exception:
207+
log.exception("Invalid add-on store cache")
208+
with open(self._cacheCompatibleOldFile, 'w', encoding='utf-8') as cacheFile:
209+
json.dump(cacheData, cacheFile, ensure_ascii=False)
210+
161211
def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]):
162212
if not NVDAState.shouldWriteToDisk():
163213
return
@@ -281,6 +331,26 @@ def getLatestAddons(
281331
return _createAddonGUICollection()
282332
return deepcopy(self._latestAddonCache.cachedAddonData)
283333

334+
def _checkForNewAddons(self) -> bool:
335+
oldAddons = self._getOldAddons()
336+
compatibleAddons = self.getLatestCompatibleAddons()
337+
installedAddons = self._installedAddonsCache._get_installedAddons()
338+
for channel in compatibleAddons:
339+
for addonId in compatibleAddons[channel]:
340+
compatibleAddon = compatibleAddons[channel][addonId]
341+
if (
342+
addonId not in oldAddons[channel]
343+
or compatibleAddon.addonVersionNumber != oldAddons[channel][addonId].addonVersionNumber
344+
) and addonId not in installedAddons:
345+
return True
346+
return False
347+
348+
def _getOldAddons(self) -> "AddonGUICollectionT":
349+
if self._oldAddonCache is None:
350+
return _createAddonGUICollection()
351+
oldAddons = deepcopy(self._oldAddonCache.cachedAddonData)
352+
return oldAddons
353+
284354
def _deleteCacheInstalledAddon(self, addonId: str):
285355
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json")
286356
if pathlib.Path(addonCachePath).exists():

source/addonStore/models/status.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,11 @@ 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]:
164+
resetNewAddonsDate = getResetNewAddonsDate()
163165
return {
164166
# Translators: The label of a tab to display installed add-ons in the add-on store.
165167
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
@@ -173,6 +175,9 @@ def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]:
173175
# Translators: The label of a tab to display incompatible add-ons in the add-on store.
174176
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
175177
self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible add-ons"),
178+
# Translators: The label of a tab to display new add-ons in the add-on store.
179+
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
180+
self.NEW: pgettext("addonStore", "Available new add-ons (%s)" % resetNewAddonsDate),
176181
}
177182

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

199208
@property
@@ -313,7 +322,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
313322
:return: Status of add-on for the context of the current tab.
314323
"""
315324

316-
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE):
325+
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW):
317326
downloadableStatus = _getDownloadableStatus(model)
318327
if downloadableStatus:
319328
# Is this available in the add-on store and not installed?
@@ -417,12 +426,19 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
417426
AvailableAddonStatus.INCOMPATIBLE_ENABLED,
418427
AvailableAddonStatus.UNKNOWN,
419428
},
429+
_StatusFilterKey.NEW: _installingStatuses
430+
.union({AvailableAddonStatus.AVAILABLE})
420431
})
421432
"""A dictionary where the keys are a status to filter by,
422433
and the values are which statuses should be shown for a given filter.
423434
"""
424435

425436

437+
def getResetNewAddonsDate() -> str:
438+
from ..dataManager import addonDataManager
439+
return addonDataManager._getResetNewAddonsDate()
440+
441+
426442
class SupportsAddonState(SupportsVersionCheck, Protocol):
427443
@property
428444
def _stateHandler(self) -> "AddonsState":

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: 17 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,21 @@ def _getAvailableAddonsInBG(self):
603604
and incompatibleAddons[channel][addonId].canOverrideCompatibility
604605
):
605606
availableAddons[channel][addonId] = incompatibleAddons[channel][addonId]
607+
elif self._filteredStatusKey == _StatusFilterKey.NEW:
608+
if addonDataManager._oldAddonCache is None:
609+
availableAddons = _createAddonGUICollection()
610+
else:
611+
oldAddons = addonDataManager._getOldAddons()
612+
newAddons = _createAddonGUICollection()
613+
for channel in availableAddons:
614+
for addonId in availableAddons[channel]:
615+
availableAddon = availableAddons[channel][addonId]
616+
if (
617+
addonId not in oldAddons[channel]
618+
or availableAddon.addonVersionNumber != oldAddons[channel][addonId].addonVersionNumber
619+
):
620+
newAddons[channel][addonId] = availableAddons[channel][addonId]
621+
availableAddons = newAddons
606622
log.debug("completed getting addons in the background")
607623
self._availableAddons = availableAddons
608624
self.listVM.resetListItems(self._createListItemVMs())
@@ -643,6 +659,7 @@ def _createListItemVMs(self) -> List[AddonListItemVM]:
643659
if self._filteredStatusKey in {
644660
_StatusFilterKey.AVAILABLE,
645661
_StatusFilterKey.UPDATE,
662+
_StatusFilterKey.NEW,
646663
}:
647664
addons = self._availableAddons
648665

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)