Skip to content

Commit 580f22c

Browse files
authored
Merge f51e88a into 4c50375
2 parents 4c50375 + f51e88a commit 580f22c

13 files changed

Lines changed: 282 additions & 28 deletions

File tree

source/addonStore/dataManager.py

Lines changed: 72 additions & 2 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,
@@ -80,6 +81,7 @@ def terminate():
8081
class _DataManager:
8182
_cacheLatestFilename: str = "_cachedLatestAddons.json"
8283
_cacheCompatibleFilename: str = "_cachedCompatibleAddons.json"
84+
_cacheCompatibleOldFilename: str = "_cachedCompatibleAddons-old.json"
8385
_downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set()
8486
_downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set()
8587

@@ -88,8 +90,10 @@ def __init__(self):
8890
self._preferredChannel = Channel.ALL
8991
self._cacheLatestFile = os.path.join(WritePaths.addonStoreDir, _DataManager._cacheLatestFilename)
9092
self._cacheCompatibleFile = os.path.join(
91-
WritePaths.addonStoreDir,
92-
_DataManager._cacheCompatibleFilename,
93+
WritePaths.addonStoreDir, _DataManager._cacheCompatibleFilename
94+
)
95+
self._cacheCompatibleOldFile = os.path.join(
96+
WritePaths.addonStoreDir, _DataManager._cacheCompatibleOldFilename
9397
)
9498
self._installedAddonDataCacheDir = WritePaths.addonsDir
9599

@@ -100,6 +104,7 @@ def __init__(self):
100104

101105
self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile)
102106
self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile)
107+
self._oldAddonCache = self._getCachedAddonData(self._cacheCompatibleOldFile)
103108
self._installedAddonsCache = _InstalledAddonsCache()
104109
# Fetch available add-ons cache early
105110
self._initialiseAvailableAddonsThread = threading.Thread(
@@ -114,6 +119,8 @@ def terminate(self):
114119
self._initialiseAvailableAddonsThread.join(timeout=1)
115120
if self._initialiseAvailableAddonsThread.is_alive():
116121
log.debugWarning("initialiseAvailableAddons thread did not terminate immediately")
122+
if self._shouldCacheCompatibleAddonsBackup():
123+
self._cacheCompatibleAddonsBackup()
117124

118125
def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
119126
url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion)
@@ -162,6 +169,49 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]):
162169
with open(self._cacheCompatibleFile, "w", encoding="utf-8") as cacheFile:
163170
json.dump(cacheData, cacheFile, ensure_ascii=False)
164171

172+
def _shouldCacheCompatibleAddonsBackup(self) -> bool:
173+
if not os.path.exists(self._cacheCompatibleOldFile):
174+
return True
175+
resetNewAddons = config.conf["addonStore"]["resetNewAddons"]
176+
if resetNewAddons == "startup":
177+
return True
178+
lastBackupTime = os.path.getmtime(self._cacheCompatibleOldFile)
179+
lastBackupDate = datetime.fromtimestamp(lastBackupTime)
180+
nowDate = datetime.now()
181+
diffDate = nowDate - lastBackupDate
182+
if resetNewAddons == "weekly" and diffDate.days >= 7:
183+
return True
184+
if resetNewAddons == "monthly" and diffDate.days >= 30:
185+
return True
186+
return False
187+
188+
def _getResetNewAddonsDate(self) -> str:
189+
resetNewAddons = config.conf["addonStore"]["resetNewAddons"]
190+
if resetNewAddons == "startup":
191+
# Translators: Message presented in the new add-ons tab, informing that new add-ons will be reset at startup
192+
return _("Will be reset at startup")
193+
lastBackupTime = os.path.getmtime(self._cacheCompatibleOldFile)
194+
lastBackupDate = datetime.fromtimestamp(lastBackupTime)
195+
formattedLastBackupDate = lastBackupDate.strftime("%d/%m/%Y")
196+
if resetNewAddons == "monthly":
197+
timedeltaDays = 30
198+
else: # weekly
199+
timedeltaDays = 7
200+
nextResetDate = lastBackupDate + timedelta(days=timedeltaDays)
201+
formattedNextResetDate = nextResetDate.strftime("%d/%m/%Y")
202+
return f"{formattedLastBackupDate}-{formattedNextResetDate}"
203+
204+
def _cacheCompatibleAddonsBackup(self):
205+
if not NVDAState.shouldWriteToDisk():
206+
return
207+
try:
208+
with open(self._cacheCompatibleFile, "r", encoding="utf-8") as cacheFile:
209+
cacheData = json.load(cacheFile)
210+
except Exception:
211+
log.exception("Invalid add-on store cache")
212+
with open(self._cacheCompatibleOldFile, "w", encoding="utf-8") as cacheFile:
213+
json.dump(cacheData, cacheFile, ensure_ascii=False)
214+
165215
def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]):
166216
if not NVDAState.shouldWriteToDisk():
167217
return
@@ -287,6 +337,26 @@ def getLatestAddons(
287337
return _createAddonGUICollection()
288338
return deepcopy(self._latestAddonCache.cachedAddonData)
289339

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

source/addonStore/models/status.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,15 @@ def _displayStringLabels(self) -> Dict["AvailableAddonStatus", str]:
110110
self.PENDING_DISABLE: pgettext("addonStore", "Disabled, pending restart"),
111111
# Translators: Status for addons shown in the add-on store dialog
112112
self.DISABLED: pgettext("addonStore", "Disabled"),
113+
# Translators: Status for addons shown in the add-on store dialog
113114
self.PENDING_INCOMPATIBLE_DISABLED: pgettext(
114-
"addonStore",
115-
# Translators: Status for addons shown in the add-on store dialog
116-
"Disabled (incompatible), pending restart",
115+
"addonStore", "Disabled (incompatible), pending restart"
117116
),
118117
# Translators: Status for addons shown in the add-on store dialog
119118
self.INCOMPATIBLE_DISABLED: pgettext("addonStore", "Disabled (incompatible)"),
119+
# Translators: Status for addons shown in the add-on store dialog
120120
self.PENDING_INCOMPATIBLE_ENABLED: pgettext(
121-
"addonStore",
122-
# Translators: Status for addons shown in the add-on store dialog
123-
"Enabled (incompatible), pending restart",
121+
"addonStore", "Enabled (incompatible), pending restart"
124122
),
125123
# Translators: Status for addons shown in the add-on store dialog
126124
self.INCOMPATIBLE_ENABLED: pgettext("addonStore", "Enabled (incompatible)"),
@@ -166,9 +164,11 @@ class _StatusFilterKey(DisplayStringEnum):
166164
UPDATE = enum.auto()
167165
AVAILABLE = enum.auto()
168166
INCOMPATIBLE = enum.auto()
167+
NEW = enum.auto()
169168

170169
@property
171170
def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]:
171+
resetNewAddonsDate = getResetNewAddonsDate()
172172
return {
173173
# Translators: The label of a tab to display installed add-ons in the add-on store.
174174
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
@@ -182,6 +182,9 @@ def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]:
182182
# Translators: The label of a tab to display incompatible add-ons in the add-on store.
183183
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
184184
self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible add-ons"),
185+
# Translators: The label of a tab to display new add-ons in the add-on store.
186+
# Ensure the translation matches the label for the add-on list which includes an accelerator key.
187+
self.NEW: pgettext("addonStore", "Available new add-ons (%s)" % resetNewAddonsDate),
185188
}
186189

187190
@property
@@ -203,6 +206,10 @@ def _displayStringLabelsWithAccelerators(self) -> Dict["_StatusFilterKey", str]:
203206
# Preferably use the same accelerator key for the four labels.
204207
# Ensure the translation matches the label for the add-on tab which has the accelerator key removed.
205208
self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible &add-ons"),
209+
# Translators: The label of the add-ons list in the corresponding panel.
210+
# Preferably use the same accelerator key for the four labels.
211+
# Ensure the translation matches the label for the add-on tab which has the accelerator key removed.
212+
self.NEW: pgettext("addonStore", "Available new &add-ons"),
206213
}
207214

208215
@property
@@ -268,7 +275,7 @@ def _getUpdateStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]:
268275
# Parsing from a side-loaded add-on
269276
try:
270277
manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(
271-
model._addonHandlerModel.version,
278+
model._addonHandlerModel.version
272279
)
273280
except ValueError:
274281
# Parsing failed to get a numeric version.
@@ -324,7 +331,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
324331
:return: Status of add-on for the context of the current tab.
325332
"""
326333

327-
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE):
334+
if context in (_StatusFilterKey.AVAILABLE, _StatusFilterKey.UPDATE, _StatusFilterKey.NEW):
328335
downloadableStatus = _getDownloadableStatus(model)
329336
if downloadableStatus:
330337
# Is this available in the add-on store and not installed?
@@ -344,10 +351,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
344351
return AvailableAddonStatus.UNKNOWN
345352

346353

347-
_addonStoreStateToAddonHandlerState: OrderedDict[
348-
AvailableAddonStatus,
349-
Set[AddonStateCategory],
350-
] = OrderedDict(
354+
_addonStoreStateToAddonHandlerState: OrderedDict[AvailableAddonStatus, Set[AddonStateCategory]] = OrderedDict(
351355
{
352356
# Pending states must be first as the pending state may be altering another state.
353357
AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED: {
@@ -371,7 +375,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
371375
AvailableAddonStatus.INCOMPATIBLE_ENABLED: {AddonStateCategory.OVERRIDE_COMPATIBILITY},
372376
AvailableAddonStatus.DISABLED: {AddonStateCategory.DISABLED},
373377
AvailableAddonStatus.INSTALLED: {AddonStateCategory.PENDING_INSTALL},
374-
},
378+
}
375379
)
376380

377381

@@ -423,7 +427,7 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
423427
{
424428
AvailableAddonStatus.INCOMPATIBLE,
425429
AvailableAddonStatus.AVAILABLE,
426-
},
430+
}
427431
),
428432
_StatusFilterKey.INCOMPATIBLE: {
429433
AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED,
@@ -432,13 +436,20 @@ def getStatus(model: "_AddonGUIModel", context: _StatusFilterKey) -> AvailableAd
432436
AvailableAddonStatus.INCOMPATIBLE_ENABLED,
433437
AvailableAddonStatus.UNKNOWN,
434438
},
435-
},
439+
_StatusFilterKey.NEW: _installingStatuses.union({AvailableAddonStatus.AVAILABLE}),
440+
}
436441
)
437442
"""A dictionary where the keys are a status to filter by,
438443
and the values are which statuses should be shown for a given filter.
439444
"""
440445

441446

447+
def getResetNewAddonsDate() -> str:
448+
from ..dataManager import addonDataManager
449+
450+
return addonDataManager._getResetNewAddonsDate()
451+
452+
442453
class SupportsAddonState(SupportsVersionCheck, Protocol):
443454
@property
444455
def _stateHandler(self) -> "AddonsState":
@@ -458,17 +469,11 @@ def isRunning(self) -> bool:
458469
def pendingInstallPath(self) -> str:
459470
from addonHandler import ADDON_PENDINGINSTALL_SUFFIX
460471

461-
return os.path.join(
462-
WritePaths.addonsDir,
463-
self.name + ADDON_PENDINGINSTALL_SUFFIX,
464-
)
472+
return os.path.join(WritePaths.addonsDir, self.name + ADDON_PENDINGINSTALL_SUFFIX)
465473

466474
@property
467475
def installPath(self) -> str:
468-
return os.path.join(
469-
WritePaths.addonsDir,
470-
self.name,
471-
)
476+
return os.path.join(WritePaths.addonsDir, self.name)
472477

473478
@property
474479
def isPendingInstall(self) -> bool:

source/config/configFlags.py

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

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,22 @@ def onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent | None):
449449
_storeVM.refresh()
450450
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.UPDATE)
451451

452+
@blockAction.when(
453+
blockAction.Context.SECURE_MODE,
454+
blockAction.Context.MODAL_DIALOG_OPEN,
455+
blockAction.Context.WINDOWS_LOCKED,
456+
blockAction.Context.WINDOWS_STORE_VERSION,
457+
blockAction.Context.RUNNING_LAUNCHER,
458+
)
459+
def onAddonStoreNewAddonsCommand(self, evt: wx.MenuEvent | None):
460+
from .addonStoreGui import AddonStoreDialog
461+
from .addonStoreGui.viewModels.store import AddonStoreVM
462+
from addonStore.models.status import _StatusFilterKey
463+
464+
_storeVM = AddonStoreVM()
465+
_storeVM.refresh()
466+
self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.NEW)
467+
452468
def onReloadPluginsCommand(self, evt):
453469
import appModuleHandler
454470
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
@@ -330,6 +330,7 @@ def _toggleFilterControls(self):
330330
if self._storeVM._filteredStatusKey in {
331331
_StatusFilterKey.AVAILABLE,
332332
_StatusFilterKey.UPDATE,
333+
_StatusFilterKey.NEW,
333334
}:
334335
if self._storeVM._filteredStatusKey == _StatusFilterKey.UPDATE and (
335336
self._storeVM._installedAddons[Channel.DEV] or self._storeVM._installedAddons[Channel.BETA]
@@ -341,6 +342,9 @@ def _toggleFilterControls(self):
341342
self.enabledFilterCtrl.Disable()
342343
self.includeIncompatibleCtrl.Enable()
343344
self.includeIncompatibleCtrl.Show()
345+
if self._storeVM._filteredStatusKey == _StatusFilterKey.NEW:
346+
self.includeIncompatibleCtrl.Disable()
347+
self.includeIncompatibleCtrl.Hide()
344348
else:
345349
self.channelFilterCtrl.Append(Channel.EXTERNAL.displayString)
346350
self._storeVM._filterChannelKey = Channel.ALL

0 commit comments

Comments
 (0)