From 3bdad2495be73dfef80354dcb48c8a3768efe572 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 16 Oct 2024 13:23:26 +0200 Subject: [PATCH] Auto trash emptying (#1014) --- ZShare/Assets/translation/translate | 2 +- Zotero.xcodeproj/project.pbxproj | 16 ++++- Zotero/Assets/en.lproj/Localizable.strings | 2 + .../Assets/en.lproj/Localizable.stringsdict | 18 ++++- .../AutoEmptyTrashController.swift | 41 ++++++++++++ Zotero/Controllers/Controllers.swift | 33 +++++----- .../Requests/AutoEmptyTrashDbRequest.swift | 41 ++++++++++++ .../MarkItemsAsTrashedDbRequest.swift | 5 +- .../MarkObjectsAsSyncedDbRequest.swift | 7 +- .../Requests/StoreCollectionsDbRequest.swift | 6 +- .../StoreItemsDbResponseRequest.swift | 5 +- Zotero/Extensions/Localizable.swift | 8 +++ Zotero/Models/Database/Database.swift | 19 +++++- Zotero/Models/Database/RCollection.swift | 7 +- Zotero/Models/Database/RItem.swift | 2 + Zotero/Models/Defaults.swift | 65 ++++++++++--------- Zotero/Models/Predicates.swift | 5 ++ .../Models/GeneralSettingsAction.swift | 1 + .../General/Models/GeneralSettingsState.swift | 10 +++ .../ViewModels/GeneralSettingsViewModel.swift | 5 ++ .../General/Views/GeneralSettingsView.swift | 20 +++--- .../List/Views/SettingsListButtonRow.swift | 4 +- bundled-styles | 2 +- locales | 2 +- note-editor | 2 +- translators | 2 +- 26 files changed, 259 insertions(+), 71 deletions(-) create mode 100644 Zotero/Controllers/AutoEmptyTrashController.swift create mode 100644 Zotero/Controllers/Database/Requests/AutoEmptyTrashDbRequest.swift diff --git a/ZShare/Assets/translation/translate b/ZShare/Assets/translation/translate index 190771fca..93ff314ae 160000 --- a/ZShare/Assets/translation/translate +++ b/ZShare/Assets/translation/translate @@ -1 +1 @@ -Subproject commit 190771fcafdc2f0dc33d4e5674b89e438a52da7a +Subproject commit 93ff314aee4d07a5b04e8ddbdc1ba90dd376e952 diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index b95ea4f70..a1287bb28 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -588,6 +588,8 @@ B3501F5425139B40007961DB /* Rounding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340692124A60D6A009ECE48 /* Rounding+Extensions.swift */; }; B3518DA72AEBCB5E00D983B4 /* SettingsResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3518DA62AEBCB5E00D983B4 /* SettingsResponseSpec.swift */; }; B351BD0E25EF7E78000451E2 /* ItemAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B351BD0D25EF7E78000451E2 /* ItemAction.swift */; }; + B352FFCC2CBE944400D0887B /* AutoEmptyTrashDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B352FFCB2CBE943B00D0887B /* AutoEmptyTrashDbRequest.swift */; }; + B352FFCD2CBE944400D0887B /* AutoEmptyTrashDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B352FFCB2CBE943B00D0887B /* AutoEmptyTrashDbRequest.swift */; }; B353F1FB242E21880062EE24 /* ResetTranslatorsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B353F1FA242E21880062EE24 /* ResetTranslatorsDbRequest.swift */; }; B353F1FC242E23680062EE24 /* ResetTranslatorsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B353F1FA242E21880062EE24 /* ResetTranslatorsDbRequest.swift */; }; B353F204242E52610062EE24 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = B353F202242E52610062EE24 /* Database.swift */; }; @@ -1018,6 +1020,7 @@ B3D32A4D286C77850075C6D7 /* ItemSortingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D32A4C286C77850075C6D7 /* ItemSortingView.swift */; }; B3D3FCA9267762EC008E243A /* ExportLocaleReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D3FCA8267762EC008E243A /* ExportLocaleReader.swift */; }; B3D4159E2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D4159D2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift */; }; + B3D427D62CB67EFC0058453A /* AutoEmptyTrashController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D427D52CB67EEA0058453A /* AutoEmptyTrashController.swift */; }; B3D58D5625ED856F00D8FA31 /* DebugLogUploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D58D5525ED856F00D8FA31 /* DebugLogUploadRequest.swift */; }; B3D58D5B25ED861500D8FA31 /* DebugLogUploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D58D5525ED856F00D8FA31 /* DebugLogUploadRequest.swift */; }; B3D58D6225EE26A600D8FA31 /* DebugResponseParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D58D6125EE26A600D8FA31 /* DebugResponseParserDelegate.swift */; }; @@ -1665,6 +1668,7 @@ B34F9FA423743C42004ED34C /* ItemTitleFormatterSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTitleFormatterSpec.swift; sourceTree = ""; }; B3518DA62AEBCB5E00D983B4 /* SettingsResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsResponseSpec.swift; sourceTree = ""; }; B351BD0D25EF7E78000451E2 /* ItemAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemAction.swift; sourceTree = ""; }; + B352FFCB2CBE943B00D0887B /* AutoEmptyTrashDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoEmptyTrashDbRequest.swift; sourceTree = ""; }; B353F1FA242E21880062EE24 /* ResetTranslatorsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetTranslatorsDbRequest.swift; sourceTree = ""; }; B353F202242E52610062EE24 /* Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; B355B12B2850B6C400BAE2C5 /* TableViewDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDiffableDataSource.swift; sourceTree = ""; }; @@ -2008,6 +2012,7 @@ B3D32A4C286C77850075C6D7 /* ItemSortingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSortingView.swift; sourceTree = ""; }; B3D3FCA8267762EC008E243A /* ExportLocaleReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportLocaleReader.swift; sourceTree = ""; }; B3D4159D2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixNotesWithEmptyTitlesDbRequest.swift; sourceTree = ""; }; + B3D427D52CB67EEA0058453A /* AutoEmptyTrashController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoEmptyTrashController.swift; sourceTree = ""; }; B3D58D5525ED856F00D8FA31 /* DebugLogUploadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLogUploadRequest.swift; sourceTree = ""; }; B3D58D6125EE26A600D8FA31 /* DebugResponseParserDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugResponseParserDelegate.swift; sourceTree = ""; }; B3D58D6A25EE437700D8FA31 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; @@ -2296,6 +2301,7 @@ B30B40642491222000FAAF6D /* AttachmentCreator.swift */, B377F2A4249373F300022943 /* AttachmentFileCleanupController.swift */, B30BA03D297ED50B0005021B /* AttributedTagStringGenerator.swift */, + B3D427D52CB67EEA0058453A /* AutoEmptyTrashController.swift */, B31D4A712767840800E22DCC /* BackgroundTaskController.swift */, B3BD2BB225C98D2900275EF9 /* BackgroundTimer.swift */, B3C8DD8127B502960084E1AD /* CollectionTreeBuilder.swift */, @@ -2310,19 +2316,20 @@ B305648123FC051E003304F2 /* Formatter.swift */, B379D9312BB30E6600AF5025 /* FullSyncDebugger.swift */, B367330C24ACB63300E0CDA8 /* HtmlAttributedStringConverter.swift */, + 618404252A4456A9005AAF22 /* IdentifierLookupController.swift */, B307A2722704A87D005986B3 /* IdleTimerController.swift */, 61639F842AE03B8500026003 /* InstantPresenter.swift */, - B30564B923FC051E003304F2 /* ItemTitleFormatter.swift */, B39649172869B0D0000BCB6C /* ISBNParser.swift */, + B30564B923FC051E003304F2 /* ItemTitleFormatter.swift */, B305648023FC051E003304F2 /* KeyGenerator.swift */, B38CD21D241128D4004299EA /* KeysResponseProcessor.swift */, B305649E23FC051E003304F2 /* Licenses.swift */, B3A17D1827FC33B800322CAD /* LowPowerModeController.swift */, B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */, B305646C23FC051E003304F2 /* ObjectUserChangeObserver.swift */, + 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */, B34A9F6325BF1ABB007C9A4A /* PDFDocumentExporter.swift */, B32B8A562B18A08900A9A741 /* PDFThumbnailController.swift */, - 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */, B3C6D551261C9F2E0068B9FE /* PlaceholderTextViewDelegate.swift */, B378F4CC242CD45700B88A05 /* RepoParserDelegate.swift */, B305646A23FC051E003304F2 /* RItemLocaleController.swift */, @@ -2337,7 +2344,6 @@ B36A988C2428E059005D5790 /* TranslatorsAndStylesController.swift */, B3972688247D403200A8B469 /* UrlDetector.swift */, B324276C25C81F2000567504 /* WebSocketController.swift */, - 618404252A4456A9005AAF22 /* IdentifierLookupController.swift */, B3F9A4CA2B04F28400684030 /* WebViewEncoder.swift */, B3B557152C884DD200BD6325 /* ZoteroURIConverter.swift */, ); @@ -2360,6 +2366,7 @@ B310FA4429E5765800FA2F15 /* AddTagsToItemDbRequest.swift */, B305646123FC051E003304F2 /* AssignItemsToCollectionsDbRequest.swift */, B305CEBA29E6E67600B9E2B4 /* AssignItemsToTagDbRequest.swift */, + B352FFCB2CBE943B00D0887B /* AutoEmptyTrashDbRequest.swift */, B37D21352AD6D8AB004A6496 /* CancelParentCreationDbRequest.swift */, B358D3B3279590C200A67054 /* CheckAnyItemIsInTrashDbRequest.swift */, B305644423FC051E003304F2 /* CheckItemIsChangedDbRequest.swift */, @@ -4768,6 +4775,7 @@ B3B1C5842664E23A00883597 /* ReadItemsForUploadDbRequest.swift in Sources */, B39649182869B0D0000BCB6C /* ISBNParser.swift in Sources */, B305662123FC051F003304F2 /* SubmitDeletionSyncAction.swift in Sources */, + B352FFCD2CBE944400D0887B /* AutoEmptyTrashDbRequest.swift in Sources */, B305661B23FC051E003304F2 /* SyncVersionsSyncAction.swift in Sources */, B3593F49241A61C700760E20 /* LibrariesAction.swift in Sources */, B3E8FE032714292E00F51458 /* StorageSettingsState.swift in Sources */, @@ -5069,6 +5077,7 @@ B30565E623FC051E003304F2 /* SchemaController.swift in Sources */, B339459126DE6C2A00E59A02 /* AttachmentFileDeletedNotification.swift in Sources */, B3593F62241A62DD00760E20 /* DetailCoordinator.swift in Sources */, + B3D427D62CB67EFC0058453A /* AutoEmptyTrashController.swift in Sources */, B30565EB23FC051E003304F2 /* Controllers.swift in Sources */, B3A351E12715784A002E597A /* WebDavDownloadRequest.swift in Sources */, B305CEBB29E6E67600B9E2B4 /* AssignItemsToTagDbRequest.swift in Sources */, @@ -5618,6 +5627,7 @@ B3E8B25027DA39B0001825F8 /* SplitAnnotationsDbRequest.swift in Sources */, B37D8E6A24DC2BF300F526C5 /* CollectionRow.swift in Sources */, B3868541270DC3AA0068A022 /* WebDavScheme.swift in Sources */, + B352FFCC2CBE944400D0887B /* AutoEmptyTrashDbRequest.swift in Sources */, B331F9AF2653CEA00099F6A6 /* ReadGroupDbRequest.swift in Sources */, B33E8A4B27B6A39100CBC7DE /* CollectionCell.swift in Sources */, B3DDC0CC2667825E00B2DFD1 /* RegularExpression+Extensions.swift in Sources */, diff --git a/Zotero/Assets/en.lproj/Localizable.strings b/Zotero/Assets/en.lproj/Localizable.strings index 978d5dc4b..76fb859f6 100644 --- a/Zotero/Assets/en.lproj/Localizable.strings +++ b/Zotero/Assets/en.lproj/Localizable.strings @@ -291,6 +291,8 @@ "settings.general.show_subcollections_title" = "Show Items from Subcollections"; "settings.general.show_collection_item_counts" = "Show collection sizes"; "settings.general.open_links_in_external_browser" = "Open links in external browser"; +"settings.general.autoempty_title" = "Delete Items in Trash"; +"settings.general.never" = "Never"; "settings.item_count" = "Item count"; "settings.item_count_subtitle" = "Show item count for all collections."; "settings.sync.title" = "Account"; diff --git a/Zotero/Assets/en.lproj/Localizable.stringsdict b/Zotero/Assets/en.lproj/Localizable.stringsdict index f82c9e1f2..017ce0406 100644 --- a/Zotero/Assets/en.lproj/Localizable.stringsdict +++ b/Zotero/Assets/en.lproj/Localizable.stringsdict @@ -195,5 +195,21 @@ Could not delete %d files from your WebDAV server + settings.general.after_x_days + + NSStringLocalizedFormatKey + %#@after_x_days@ + after_x_days + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + After 1 Day + other + After %d Days + + - \ No newline at end of file + diff --git a/Zotero/Controllers/AutoEmptyTrashController.swift b/Zotero/Controllers/AutoEmptyTrashController.swift new file mode 100644 index 000000000..fffeb004c --- /dev/null +++ b/Zotero/Controllers/AutoEmptyTrashController.swift @@ -0,0 +1,41 @@ +// +// AutoEmptyTrashController.swift +// Zotero +// +// Created by Michal Rentka on 09.10.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import CocoaLumberjackSwift + +final class AutoEmptyTrashController { + private unowned let dbStorage: DbStorage + private let queue: DispatchQueue + + init(dbStorage: DbStorage) { + self.dbStorage = dbStorage + queue = DispatchQueue(label: "org.zotero.AutoEmptyTrashController.queue", qos: .utility) + } + + func autoEmptyIfNeeded() { + if Defaults.shared.trashAutoEmptyThreshold == 0 { + DDLogInfo("AutoEmptyTrashController: auto emptying disabled") + return + } + + // Auto empty trash once a day + guard Date.now.timeIntervalSince(Defaults.shared.trashLastAutoEmptyDate) >= 86400 else { return } + + DDLogInfo("AutoEmptyTrashController: perform auto empty") + Defaults.shared.trashLastAutoEmptyDate = .now + + queue.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in + guard let self else { return } + do { + try dbStorage.perform(request: AutoEmptyTrashDbRequest(libraryId: .custom(.myLibrary)), on: queue) + } catch let error { + DDLogError("AutoEmptyTrashController: can't empty trash - \(error)") + } + } + } +} diff --git a/Zotero/Controllers/Controllers.swift b/Zotero/Controllers/Controllers.swift index 9d4f0a73e..ffbcdcb37 100644 --- a/Zotero/Controllers/Controllers.swift +++ b/Zotero/Controllers/Controllers.swift @@ -299,6 +299,7 @@ final class Controllers { /// Global controllers for logged in user final class UserControllers { + let autoEmptyController: AutoEmptyTrashController let syncScheduler: (SynchronizationScheduler & WebSocketScheduler) let changeObserver: ObjectUserChangeObserver let dbStorage: DbStorage @@ -376,16 +377,17 @@ final class UserControllers { } }) + autoEmptyController = AutoEmptyTrashController(dbStorage: dbStorage) self.isFirstLaunch = isFirstLaunch self.dbStorage = dbStorage - self.syncScheduler = SyncScheduler(controller: syncController, retryIntervals: DelayIntervals.retry) + syncScheduler = SyncScheduler(controller: syncController, retryIntervals: DelayIntervals.retry) self.webDavController = webDavController - self.changeObserver = RealmObjectUserChangeObserver(dbStorage: dbStorage) - self.itemLocaleController = RItemLocaleController(schemaController: controllers.schemaController, dbStorage: dbStorage) + changeObserver = RealmObjectUserChangeObserver(dbStorage: dbStorage) + itemLocaleController = RItemLocaleController(schemaController: controllers.schemaController, dbStorage: dbStorage) self.backgroundUploadObserver = backgroundUploadObserver self.fileDownloader = fileDownloader - self.remoteFileDownloader = RemoteAttachmentDownloader(apiClient: controllers.apiClient, fileStorage: controllers.fileStorage) - self.identifierLookupController = IdentifierLookupController( + remoteFileDownloader = RemoteAttachmentDownloader(apiClient: controllers.apiClient, fileStorage: controllers.fileStorage) + identifierLookupController = IdentifierLookupController( dbStorage: dbStorage, fileStorage: controllers.fileStorage, translatorsController: controllers.translatorsAndStylesController, @@ -395,23 +397,24 @@ final class UserControllers { ) self.webSocketController = webSocketController self.fileCleanupController = fileCleanupController - self.citationController = CitationController( + citationController = CitationController( stylesController: controllers.translatorsAndStylesController, fileStorage: controllers.fileStorage, dbStorage: dbStorage, bundledDataStorage: controllers.bundledDataStorage ) - self.translatorsAndStylesController = controllers.translatorsAndStylesController + translatorsAndStylesController = controllers.translatorsAndStylesController fullSyncDebugger = FullSyncDebugger(syncScheduler: syncScheduler, debugLogging: controllers.debugLogging, sessionController: controllers.sessionController) - self.idleTimerController = controllers.idleTimerController - self.customUrlController = CustomURLController(dbStorage: dbStorage, fileStorage: controllers.fileStorage) - self.lastBuildNumber = controllers.lastBuildNumber - self.disposeBag = DisposeBag() + idleTimerController = controllers.idleTimerController + customUrlController = CustomURLController(dbStorage: dbStorage, fileStorage: controllers.fileStorage) + lastBuildNumber = controllers.lastBuildNumber + disposeBag = DisposeBag() } /// Connects to websocket to monitor changes and performs initial sync. fileprivate func enableSync(apiKey: String) { - self.itemLocaleController.loadLocale() + itemLocaleController.loadLocale() + autoEmptyController.autoEmptyIfNeeded() // Enable idleTimerController before syncScheduler inProgress observation starts idleTimerController.enable() @@ -443,7 +446,7 @@ final class UserControllers { .disposed(by: disposeBag) // Observe local changes to start sync - self.changeObserver.observable + changeObserver.observable .debounce(.seconds(3), scheduler: MainScheduler.instance) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] changedLibraries in @@ -452,7 +455,7 @@ final class UserControllers { .disposed(by: self.disposeBag) // Observe remote changes to start sync/translator update - self.webSocketController.observable + webSocketController.observable .debounce(.seconds(3), scheduler: MainScheduler.instance) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] change in @@ -467,7 +470,7 @@ final class UserControllers { .disposed(by: self.disposeBag) // Connect to websockets and start sync - self.webSocketController.connect(apiKey: apiKey, completed: { [weak self] in + webSocketController.connect(apiKey: apiKey, completed: { [weak self] in guard let self = self else { return } // Call this before sync so that background uploads are updated and taken care of by sync if needed. self.backgroundUploadObserver.updateSessions() diff --git a/Zotero/Controllers/Database/Requests/AutoEmptyTrashDbRequest.swift b/Zotero/Controllers/Database/Requests/AutoEmptyTrashDbRequest.swift new file mode 100644 index 000000000..b33cc8291 --- /dev/null +++ b/Zotero/Controllers/Database/Requests/AutoEmptyTrashDbRequest.swift @@ -0,0 +1,41 @@ +// +// AutoEmptyTrashDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 15.10.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import CocoaLumberjackSwift +import RealmSwift + +struct AutoEmptyTrashDbRequest: DbRequest { + let libraryId: LibraryIdentifier + + var needsWrite: Bool { return true } + + func process(in database: Realm) throws { + let threshold = Defaults.shared.trashAutoEmptyThreshold + var count = 0 + database.objects(RItem.self).filter(.items(for: .custom(.trash), libraryId: libraryId)).filter("trashDate != nil").forEach { + guard let date = $0.trashDate, shouldDelete(date: date) else { return } + $0.deleted = true + $0.changeType = .user + count += 1 + } + DDLogInfo("Auto emptied \(count) items") + count = 0 + database.objects(RCollection.self).filter(.trashedCollections(in: .custom(.myLibrary))).filter("trashDate != nil").forEach { + guard let date = $0.trashDate, shouldDelete(date: date) else { return } + $0.deleted = true + $0.changeType = .user + count += 1 + } + DDLogInfo("Auto emptied \(count) collections") + + func shouldDelete(date: Date) -> Bool { + let daysSinceTrashed = Int(Date.now.timeIntervalSince(date) / 86400) + return daysSinceTrashed >= threshold + } + } +} diff --git a/Zotero/Controllers/Database/Requests/MarkItemsAsTrashedDbRequest.swift b/Zotero/Controllers/Database/Requests/MarkItemsAsTrashedDbRequest.swift index 41276ee88..6e0837696 100644 --- a/Zotero/Controllers/Database/Requests/MarkItemsAsTrashedDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/MarkItemsAsTrashedDbRequest.swift @@ -19,8 +19,11 @@ struct MarkItemsAsTrashedDbRequest: DbRequest { func process(in database: Realm) throws { let items = database.objects(RItem.self).filter(.keys(self.keys, in: self.libraryId)) + let now = Date.now items.forEach { item in - item.trash = self.trashed + item.trash = trashed + item.trashDate = trashed ? now : nil + item.dateModified = now item.changeType = .user item.changes.append(RObjectChange.create(changes: RItemChanges.trash)) } diff --git a/Zotero/Controllers/Database/Requests/MarkObjectsAsSyncedDbRequest.swift b/Zotero/Controllers/Database/Requests/MarkObjectsAsSyncedDbRequest.swift index 6618d8810..5fef6b510 100644 --- a/Zotero/Controllers/Database/Requests/MarkObjectsAsSyncedDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/MarkObjectsAsSyncedDbRequest.swift @@ -93,9 +93,13 @@ struct MarkCollectionAsSyncedAndUpdateDbRequest: DbRequest { } collection.version = response.version - collection.trash = response.data.isTrash collection.changeType = .syncResponse + if !localChanges.contains(.trash) { + collection.trash = response.data.isTrash + collection.trashDate = response.data.isTrash ? Date.now : nil + } + if !localChanges.contains(.name) { collection.name = response.data.name } @@ -152,6 +156,7 @@ struct MarkItemAsSyncedAndUpdateDbRequest: DbRequest { if !localChanges.contains(.trash) { item.trash = response.isTrash + item.trashDate = response.isTrash ? Date.now : nil } if !localChanges.contains(.parent) && item.parent?.key != response.parentKey { diff --git a/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift b/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift index df57ab0df..54040ac36 100644 --- a/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreCollectionsDbRequest.swift @@ -37,6 +37,7 @@ struct StoreCollectionsDbRequest: DbRequest { if collection.deleted { for item in collection.items { item.trash = false + item.trashDate = nil item.deleted = false } } @@ -56,7 +57,10 @@ struct StoreCollectionsDbRequest: DbRequest { collection.lastSyncDate = Date(timeIntervalSince1970: 0) collection.changeType = .sync collection.libraryId = libraryId - collection.trash = response.data.isTrash + if collection.trash != response.data.isTrash { + collection.trash = response.data.isTrash + collection.trashDate = collection.trash ? Date.now : nil + } self.sync(parentCollection: response.data.parentCollection, libraryId: libraryId, collection: collection, database: database) } diff --git a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift index 656514ccc..83a63b34c 100644 --- a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift @@ -132,7 +132,10 @@ struct StoreItemDbRequest: DbResponseRequest { item.localizedType = schemaController.localized(itemType: response.rawType) ?? "" item.inPublications = response.inPublications item.version = response.version - item.trash = response.isTrash + if item.trash != response.isTrash { + item.trash = response.isTrash + item.trashDate = item.trash ? Date.now : nil + } item.dateModified = response.dateModified item.dateAdded = response.dateAdded item.syncState = .synced diff --git a/Zotero/Extensions/Localizable.swift b/Zotero/Extensions/Localizable.swift index dd67a1822..594e448e2 100644 --- a/Zotero/Extensions/Localizable.swift +++ b/Zotero/Extensions/Localizable.swift @@ -1240,6 +1240,14 @@ internal enum L10n { internal static let start = L10n.tr("Localizable", "settings.full_sync.start", fallback: "Start Full Sync Debugging") } internal enum General { + /// Plural format key: "%#@after_x_days@" + internal static func afterXDays(_ p1: Int) -> String { + return L10n.tr("Localizable", "settings.general.after_x_days", p1, fallback: "Plural format key: \"%#@after_x_days@\"") + } + /// Delete Items in Trash + internal static let autoemptyTitle = L10n.tr("Localizable", "settings.general.autoempty_title", fallback: "Delete Items in Trash") + /// Never + internal static let never = L10n.tr("Localizable", "settings.general.never", fallback: "Never") /// Open links in external browser internal static let openLinksInExternalBrowser = L10n.tr("Localizable", "settings.general.open_links_in_external_browser", fallback: "Open links in external browser") /// Show collection sizes diff --git a/Zotero/Models/Database/Database.swift b/Zotero/Models/Database/Database.swift index 763fb2f6d..008c77fb4 100644 --- a/Zotero/Models/Database/Database.swift +++ b/Zotero/Models/Database/Database.swift @@ -13,7 +13,7 @@ import RealmSwift import Network struct Database { - private static let schemaVersion: UInt64 = 46 + private static let schemaVersion: UInt64 = 47 static func mainConfiguration(url: URL, fileStorage: FileStorage) -> Realm.Configuration { var config = Realm.Configuration( @@ -89,6 +89,23 @@ struct Database { if schemaVersion < 46 { correctAnnotationColors(migration: migration) } + if schemaVersion < 47 { + setTrashDates(migration: migration) + } + } + } + + private static func setTrashDates(migration: Migration) { + let now = Date.now + migration.enumerateObjects(ofType: RItem.className()) { oldObject, newObject in + if let isTrashed = oldObject?["trash"] as? Bool, isTrashed { + newObject?["trashDate"] = now + } + } + migration.enumerateObjects(ofType: RCollection.className()) { oldObject, newObject in + if let isTrashed = oldObject?["trash"] as? Bool, isTrashed { + newObject?["trashDate"] = now + } } } diff --git a/Zotero/Models/Database/RCollection.swift b/Zotero/Models/Database/RCollection.swift index 3a71c9b12..72bdfaa1d 100644 --- a/Zotero/Models/Database/RCollection.swift +++ b/Zotero/Models/Database/RCollection.swift @@ -23,11 +23,12 @@ struct RCollectionChanges: OptionSet { extension RCollectionChanges { static let name = RCollectionChanges(rawValue: 1 << 0) static let parent = RCollectionChanges(rawValue: 1 << 1) - static let all: RCollectionChanges = [.name, .parent] + static let trash = RCollectionChanges(rawValue: 1 << 2) + static let all: RCollectionChanges = [.name, .parent, .trash] } final class RCollection: Object { - static let observableKeypathsForList: [String] = ["name", "parentKey", "items"] + static let observableKeypathsForList: [String] = ["name", "parentKey", "items", "trash"] @Persisted(indexed: true) var key: String @Persisted var name: String @@ -40,6 +41,8 @@ final class RCollection: Object { @Persisted var groupKey: Int? /// Indicates which local changes need to be synced to backend @Persisted var changes: List + /// Date indicating when this collection was moved to trash + @Persisted var trashDate: Date? // MARK: - Sync data /// Indicates local version of object diff --git a/Zotero/Models/Database/RItem.swift b/Zotero/Models/Database/RItem.swift index 30286c754..f5ade57e1 100644 --- a/Zotero/Models/Database/RItem.swift +++ b/Zotero/Models/Database/RItem.swift @@ -77,6 +77,8 @@ final class RItem: Object { @Persisted var changes: List /// Indicates whether `SyncController` should try to sync `changes` @Persisted var changesSyncPaused: Bool + /// Date indicating when this item was moved to trash + @Persisted var trashDate: Date? // MARK: - Attachment data @Persisted var backendMd5: String diff --git a/Zotero/Models/Defaults.swift b/Zotero/Models/Defaults.swift index cf4f94859..ce1f74f14 100644 --- a/Zotero/Models/Defaults.swift +++ b/Zotero/Models/Defaults.swift @@ -69,6 +69,12 @@ final class Defaults { @UserDefault(key: "QuickCopyAsHtml", defaultValue: false, defaults: .standard) var quickCopyAsHtml: Bool + @UserDefault(key: "TrashAutoEmptyDayThreshold", defaultValue: 30, defaults: .standard) + var trashAutoEmptyThreshold: Int + + @UserDefault(key: "TrashLastAutoEmptyDate", defaultValue: .distantPast, defaults: .standard) + var trashLastAutoEmptyDate: Date + // MARK: - Selection @CodableUserDefault(key: "SelectedRawLibraryKey", defaultValue: LibraryIdentifier.custom(.myLibrary), encoder: Defaults.jsonEncoder, decoder: Defaults.jsonDecoder) @@ -172,37 +178,38 @@ final class Defaults { // MARK: - Actions func reset() { - self.askForSyncPermission = false - self.username = "" - self.displayName = "" - self.userId = 0 - self.shareExtensionIncludeTags = true - self.shareExtensionIncludeAttachment = true - self.selectedLibrary = .custom(.myLibrary) - self.selectedCollectionId = .custom(.all) - self.webDavUrl = nil - self.webDavScheme = .https - self.webDavEnabled = false - self.webDavUsername = nil - self.webDavVerified = false - self.quickCopyLocaleId = "en-US" - self.quickCopyAsHtml = false - self.quickCopyStyleId = "http://www.zotero.org/styles/chicago-note-bibliography" - self.showSubcollectionItems = false + askForSyncPermission = false + username = "" + displayName = "" + userId = 0 + shareExtensionIncludeTags = true + shareExtensionIncludeAttachment = true + selectedLibrary = .custom(.myLibrary) + selectedCollectionId = .custom(.all) + webDavUrl = nil + webDavScheme = .https + webDavEnabled = false + webDavUsername = nil + webDavVerified = false + quickCopyLocaleId = "en-US" + quickCopyAsHtml = false + quickCopyStyleId = "http://www.zotero.org/styles/chicago-note-bibliography" + showSubcollectionItems = false + trashAutoEmptyThreshold = 30 + trashLastAutoEmptyDate = .distantPast #if MAINAPP - self.itemsSortType = .default - self.exportOutputMethod = .copy - self.exportOutputMode = .bibliography - - self.activeLineWidth = 1 - self.inkColorHex = AnnotationsConfig.defaultActiveColor - self.squareColorHex = AnnotationsConfig.defaultActiveColor - self.noteColorHex = AnnotationsConfig.defaultActiveColor - self.highlightColorHex = AnnotationsConfig.defaultActiveColor - self.underlineColorHex = AnnotationsConfig.defaultActiveColor - self.textColorHex = AnnotationsConfig.defaultActiveColor - self.pdfSettings = PDFSettings.default + itemsSortType = .default + exportOutputMethod = .copy + exportOutputMode = .bibliography + activeLineWidth = 1 + inkColorHex = AnnotationsConfig.defaultActiveColor + squareColorHex = AnnotationsConfig.defaultActiveColor + noteColorHex = AnnotationsConfig.defaultActiveColor + highlightColorHex = AnnotationsConfig.defaultActiveColor + underlineColorHex = AnnotationsConfig.defaultActiveColor + textColorHex = AnnotationsConfig.defaultActiveColor + pdfSettings = PDFSettings.default #endif } } diff --git a/Zotero/Models/Predicates.swift b/Zotero/Models/Predicates.swift index 5fe87aa25..f90f7c737 100644 --- a/Zotero/Models/Predicates.swift +++ b/Zotero/Models/Predicates.swift @@ -215,6 +215,11 @@ extension NSPredicate { return NSPredicate(format: "trash = %@", NSNumber(booleanLiteral: trash)) } + static func trashedCollections(in libraryId: LibraryIdentifier) -> NSPredicate { + let predicates: [NSPredicate] = [.library(with: libraryId), .deleted(false), .isTrash(true), .notSyncState(.dirty)] + return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + private static func baseItemPredicates(isTrash: Bool, libraryId: LibraryIdentifier) -> [NSPredicate] { var predicates: [NSPredicate] = [.library(with: libraryId), .notSyncState(.dirty), .deleted(false), .isTrash(isTrash)] if !isTrash { diff --git a/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsAction.swift b/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsAction.swift index 248a40c2c..28e2901d4 100644 --- a/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsAction.swift +++ b/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsAction.swift @@ -12,4 +12,5 @@ enum GeneralSettingsAction { case setShowSubcollectionItems(Bool) case setShowCollectionItemCounts(Bool) case setOpenLinksInExternalBrowser(Bool) + case setAutoEmptyTrashThreshold(Int) } diff --git a/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsState.swift b/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsState.swift index c1da406f6..eb5364af6 100644 --- a/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsState.swift +++ b/Zotero/Scenes/Master/Settings/General/Models/GeneralSettingsState.swift @@ -39,5 +39,15 @@ struct GeneralSettingsState: ViewModelState { } } + var autoEmptyTrashThreshold: Int { + get { + return Defaults.shared.trashAutoEmptyThreshold + } + + set { + Defaults.shared.trashAutoEmptyThreshold = newValue + } + } + func cleanup() {} } diff --git a/Zotero/Scenes/Master/Settings/General/ViewModels/GeneralSettingsViewModel.swift b/Zotero/Scenes/Master/Settings/General/ViewModels/GeneralSettingsViewModel.swift index b4857beb1..f4670f695 100644 --- a/Zotero/Scenes/Master/Settings/General/ViewModels/GeneralSettingsViewModel.swift +++ b/Zotero/Scenes/Master/Settings/General/ViewModels/GeneralSettingsViewModel.swift @@ -28,6 +28,11 @@ struct GeneralSettingsActionHandler: ViewModelActionHandler { update(viewModel: viewModel) { state in state.openLinksInExternalBrowser = value } + + case .setAutoEmptyTrashThreshold(let value): + update(viewModel: viewModel) { state in + state.autoEmptyTrashThreshold = value + } } } } diff --git a/Zotero/Scenes/Master/Settings/General/Views/GeneralSettingsView.swift b/Zotero/Scenes/Master/Settings/General/Views/GeneralSettingsView.swift index ae79b13bc..d74810ee1 100644 --- a/Zotero/Scenes/Master/Settings/General/Views/GeneralSettingsView.swift +++ b/Zotero/Scenes/Master/Settings/General/Views/GeneralSettingsView.swift @@ -16,27 +16,29 @@ struct GeneralSettingsView: View { SettingsToggleRow( title: L10n.Settings.General.showSubcollectionsTitle, subtitle: nil, - value: self.viewModel.binding(keyPath: \.showSubcollectionItems, action: { .setShowSubcollectionItems($0) }) + value: viewModel.binding(keyPath: \.showSubcollectionItems, action: { .setShowSubcollectionItems($0) }) ) SettingsToggleRow( title: L10n.Settings.General.showCollectionItemCounts, subtitle: nil, - value: self.viewModel.binding(keyPath: \.showCollectionItemCounts, action: { .setShowCollectionItemCounts($0) }) + value: viewModel.binding(keyPath: \.showCollectionItemCounts, action: { .setShowCollectionItemCounts($0) }) ) SettingsToggleRow( title: L10n.Settings.General.openLinksInExternalBrowser, subtitle: nil, - value: self.viewModel.binding(keyPath: \.openLinksInExternalBrowser, action: { .setOpenLinksInExternalBrowser($0) }) + value: viewModel.binding(keyPath: \.openLinksInExternalBrowser, action: { .setOpenLinksInExternalBrowser($0) }) ) + + Picker(L10n.Settings.General.autoemptyTitle, selection: viewModel.binding(get: \.autoEmptyTrashThreshold, action: { .setAutoEmptyTrashThreshold($0) })) { + Text(L10n.Settings.General.afterXDays(1)).tag(1) + Text(L10n.Settings.General.afterXDays(7)).tag(7) + Text(L10n.Settings.General.afterXDays(15)).tag(15) + Text(L10n.Settings.General.afterXDays(30)).tag(30) + Text(L10n.Settings.General.never).tag(0) + } } .navigationBarTitle(L10n.Settings.General.title) } } - -struct GeneralSettingsView_Previews: PreviewProvider { - static var previews: some View { - GeneralSettingsView() - } -} diff --git a/Zotero/Scenes/Master/Settings/List/Views/SettingsListButtonRow.swift b/Zotero/Scenes/Master/Settings/List/Views/SettingsListButtonRow.swift index 9c7e6721e..d0df04a79 100644 --- a/Zotero/Scenes/Master/Settings/List/Views/SettingsListButtonRow.swift +++ b/Zotero/Scenes/Master/Settings/List/Views/SettingsListButtonRow.swift @@ -22,11 +22,11 @@ struct SettingsListButtonRow: View { if let text = self.detailText { Text(text) - .foregroundColor(Color(self.textColor)) + .foregroundColor(Color(UIColor.systemGray)) } Image(systemName: "chevron.right") - .foregroundColor(Color(UIColor.systemGray2)) + .foregroundColor(Color(UIColor.systemGray)) .font(.body.weight(.semibold)) .imageScale(.small) .opacity(0.7) diff --git a/bundled-styles b/bundled-styles index e4a9d3cdb..a653811f3 160000 --- a/bundled-styles +++ b/bundled-styles @@ -1 +1 @@ -Subproject commit e4a9d3cdb5ea2f627f81336b01aeeb39346fb8c7 +Subproject commit a653811f3eaa0e9d849ab6019493c7b8867fc96f diff --git a/locales b/locales index e631a52dc..8bc2af16f 160000 --- a/locales +++ b/locales @@ -1 +1 @@ -Subproject commit e631a52dcea396be20d031b6456e91dba7772224 +Subproject commit 8bc2af16f5180a8e4fb591c2be916650f75bb8f6 diff --git a/note-editor b/note-editor index e00c5c4bf..0f15a7f7e 160000 --- a/note-editor +++ b/note-editor @@ -1 +1 @@ -Subproject commit e00c5c4bf2ac60ced93e5ff03e95966b05cf588f +Subproject commit 0f15a7f7ea4077f567f0cebbc847b6337b70ba3c diff --git a/translators b/translators index c528844c3..017fdf0f4 160000 --- a/translators +++ b/translators @@ -1 +1 @@ -Subproject commit c528844c3612b8e77eb494eec9d7bf5a6997bf1d +Subproject commit 017fdf0f44b59cfe329622dbc48276976d7e288c