diff --git a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift index c421ad5d..6a85a56d 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift @@ -31,7 +31,7 @@ public final class FilesDatabaseManager: Sendable { ) } - private static let schemaVersion = SchemaVersion.addedIsLockFileOfLocalOriginToRealmItemMetadata + private static let schemaVersion = SchemaVersion.addedWasTrashedLocallyToRealmItemMetadata let logger: FileProviderLogger let account: Account @@ -86,6 +86,15 @@ public final class FilesDatabaseManager: Sendable { } } + if oldSchemaVersion == SchemaVersion.addedIsLockFileOfLocalOriginToRealmItemMetadata.rawValue { + migration.enumerateObjects(ofType: RealmItemMetadata.className()) { _, newObject in + guard let newObject else { + return + } + + newObject["wasTrashedLocally"] = false + } + } }, objectTypes: [RealmItemMetadata.self, RemoteFileChunk.self] ) @@ -430,7 +439,7 @@ public final class FilesDatabaseManager: Sendable { do { try database.write { database.add(RealmItemMetadata(value: metadata), update: .all) - logger.debug("Added item metadata.", [.item: metadata.ocId, .name: metadata.name, .url: metadata.serverUrl]) + logger.debug("Added item metadata.", [.item: metadata.ocId, .name: metadata.fileName, .url: metadata.serverUrl]) } } catch { logger.error("Failed to add item metadata.", [.item: metadata.ocId, .name: metadata.name, .url: metadata.serverUrl, .error: error]) diff --git a/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift b/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift index 8019e210..db699f5d 100644 --- a/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift +++ b/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift @@ -9,4 +9,5 @@ enum SchemaVersion: UInt64 { case deletedLocalFileMetadata = 200 case addedLockTokenPropertyToRealmItemMetadata = 201 case addedIsLockFileOfLocalOriginToRealmItemMetadata = 202 + case addedWasTrashedLocallyToRealmItemMetadata = 203 } diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift index d868a3d3..51130273 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift @@ -5,96 +5,29 @@ import NextcloudKit extension Enumerator { - static func completeEnumerationObserver( - _ observer: NSFileProviderEnumerationObserver, - account: Account, - remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager, - numPage: Int, - trashItems: [NKTrash], - log: any FileProviderLogging - ) { - var metadatas = [SendableItemMetadata]() - for trashItem in trashItems { - let metadata = trashItem.toItemMetadata(account: account) - dbManager.addItemMetadata(metadata) - metadatas.append(metadata) - } - - Task { [metadatas] in - let logger = FileProviderLogger(category: "Enumerator", log: log) - - do { - let items = try await metadatas.toFileProviderItems(account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: log) - - Task { @MainActor in - observer.didEnumerate(items) - logger.info("Did enumerate \(items.count) trash items.") - observer.finishEnumerating(upTo: fileProviderPageforNumPage(numPage)) - } - } catch { - logger.error("Finishing enumeration with error.") - Task { @MainActor in observer.finishEnumeratingWithError(error) } - } - } - } - - static func completeChangesObserver( - _ observer: NSFileProviderChangeObserver, - anchor: NSFileProviderSyncAnchor, - account: Account, - remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager, - trashItems: [NKTrash], - log: any FileProviderLogging - ) async { + /// + /// Change enumeration completion. + /// + /// `NKTrash` items do not have an ETag. We assume they cannot be modified while they are in the trash. So we will just check by their `ocId`. + /// Newly added items by deletion on the server side or another client are not of interested and we do not want to display them in the local trash. + /// In the end, only the remotely and permanently deleted items are of interest. + /// + static func completeChangesObserver(_ observer: NSFileProviderChangeObserver, anchor: NSFileProviderSyncAnchor, account: Account, dbManager: FilesDatabaseManager, remoteTrashItems: [NKTrash], log: any FileProviderLogging) async { let logger = FileProviderLogger(category: "Enumerator", log: log) - var newTrashedItems = [NSFileProviderItem]() - - // NKTrash items do not have an etag ; we assume they cannot be modified while they are in - // the trash, so we will just check by ocId - var existingTrashedItems = dbManager.trashedItemMetadatas(account: account) - - for trashItem in trashItems { - if let existingTrashItemIndex = existingTrashedItems.firstIndex( - where: { $0.ocId == trashItem.ocId } - ) { - existingTrashedItems.remove(at: existingTrashItemIndex) - continue - } - - let metadata = trashItem.toItemMetadata(account: account) - dbManager.addItemMetadata(metadata) - - let item = await Item( - metadata: metadata, - parentItemIdentifier: .trashContainer, - account: account, - remoteInterface: remoteInterface, - dbManager: dbManager, - remoteSupportsTrash: remoteInterface.supportsTrash(account: account), - log: log - ) - newTrashedItems.append(item) - - logger.debug("Will enumerate changed trash item.", [.item: metadata.ocId, .name: metadata.fileName]) - } - - let deletedTrashedItemsIdentifiers = existingTrashedItems.map { - NSFileProviderItemIdentifier($0.ocId) - } - if !deletedTrashedItemsIdentifiers.isEmpty { - for itemIdentifier in deletedTrashedItemsIdentifiers { - dbManager.deleteItemMetadata(ocId: itemIdentifier.rawValue) - } - - logger.debug("Will enumerate deleted trashed items: \(deletedTrashedItemsIdentifiers)") - observer.didDeleteItems(withIdentifiers: deletedTrashedItemsIdentifiers) + let localIdentifiers = dbManager.trashedItemMetadatas(account: account).map(\.ocId) + let localSet = Set(localIdentifiers) + let remoteIdentifiers = remoteTrashItems.map(\.ocId) + let remoteSet = Set(remoteIdentifiers) + let orphanedSet = localSet.subtracting(remoteSet) + let orphanedIdentifiers = orphanedSet.map { NSFileProviderItemIdentifier($0) } + + for identifier in orphanedSet { + logger.info("Permanently deleting remote trash item which could not be matched with a local one.", [.item: identifier]) + dbManager.deleteItemMetadata(ocId: identifier) } - if !newTrashedItems.isEmpty { - observer.didUpdate(newTrashedItems) - } + observer.didDeleteItems(withIdentifiers: orphanedIdentifiers) observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + logger.debug("Finished enumerating remote changes in trash.") } } diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 99cbaa76..e8820c97 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -76,21 +76,8 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { public func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { logger.info("Received enumerate items request for enumerator with user", [.account: account.ncKitAccount, .url: serverUrl]) - /* - - inspect the page to determine whether this is an initial or a follow-up request (TODO) - - If this is an enumerator for a directory, the root container or all directories: - - perform a server request to fetch directory contents - If this is an enumerator for the working set: - - perform a server request to update your local database - - fetch the working set from your local database - - - inform the observer about the items returned by the server (possibly multiple times) - - inform the observer that you are finished with this page - */ - if enumeratedItemIdentifier == .trashContainer { - logger.info("Enumerating trash.", [.account: account.ncKitAccount, .url: serverUrl]) + logger.info("Enumerating items in trash.", [.account: account.ncKitAccount, .url: serverUrl]) Task { [weak self] in guard let self else { @@ -111,42 +98,11 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { return } - let domain = domain - let enumeratedItemIdentifier = enumeratedItemIdentifier - - let (_, trashedItems, _, trashReadError) = await remoteInterface.listingTrashAsync( - filename: nil, - showHiddenFiles: true, - account: account.ncKitAccount, - options: .init(), - taskHandler: { task in - if let domain { - NSFileProviderManager(for: domain)?.register( - task, - forItemWithIdentifier: enumeratedItemIdentifier, - completionHandler: { _ in } - ) - } - } - ) - - guard trashReadError == .success else { - let error = trashReadError.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) - observer.finishEnumeratingWithError(error) - return - } - - Self.completeEnumerationObserver( - observer, - account: account, - remoteInterface: remoteInterface, - dbManager: dbManager, - numPage: 1, - trashItems: trashedItems ?? [], - log: logger.log - ) + // We only want to list items deleted on the local device. + // That cannot happen before the initial content enumeration for a file provider domain because the latter does not exist yet. + // Hence the initial trash content enumeration can be finished with an empty set. + observer.finishEnumerating(upTo: Self.fileProviderPageforNumPage(1)) } - return } @@ -334,9 +290,8 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { observer, anchor: anchor, account: account, - remoteInterface: remoteInterface, dbManager: dbManager, - trashItems: trashedItems ?? [], + remoteTrashItems: trashedItems ?? [], log: logger.log ) } diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift index 73ff8e4b..cf0fc4fc 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift @@ -15,7 +15,7 @@ extension NKFile { return fileUrl == urlString } - func toItemMetadata(uploaded: Bool = true) -> SendableItemMetadata { + func toItemMetadata(uploaded: Bool = true, wasTrashedLocally: Bool = false) -> SendableItemMetadata { let creationDate = creationDate ?? date let uploadDate = uploadDate ?? date @@ -88,7 +88,8 @@ extension NKFile { uploadDate: uploadDate as Date, urlBase: urlBase, user: user, - userId: userId + userId: userId, + wasTrashedLocally: wasTrashedLocally ) } } diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift index 19ca9c68..62710b48 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift @@ -5,7 +5,10 @@ import Foundation import NextcloudKit extension NKTrash { - func toItemMetadata(account: Account) -> SendableItemMetadata { + /// + /// Convert a trashed item representation into sendable item metadata. + /// + func toItemMetadata(account: Account, wasTrashedLocally: Bool = false) -> SendableItemMetadata { SendableItemMetadata( ocId: ocId, account: account.ncKitAccount, @@ -35,7 +38,8 @@ extension NKTrash { trashbinDeletionTime: trashbinDeletionTime, urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: wasTrashedLocally ) } } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift index 05f6ae3e..6c457ee9 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift @@ -213,7 +213,8 @@ public extension Item { uploaded: true, urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: false ) dbManager.addItemMetadata(newMetadata) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift index 8f30b6d7..3f93aa4e 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift @@ -52,7 +52,8 @@ extension Item { uploaded: false, urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: false ) dbManager.addItemMetadata(metadata) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift index 8c5b993c..2624e881 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -126,7 +126,8 @@ extension Item { uploaded: false, urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: false ) dbManager.addItemMetadata(metadata) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift index 749aadf6..c4dfb4a2 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift @@ -656,13 +656,13 @@ public extension Item { return (modifiedItem, nil) } else if changedFields.contains(.parentItemIdentifier) && newParentItemIdentifier == .trashContainer { - let (_, capabilities, _, error) = await remoteInterface.currentCapabilities( - account: account, options: .init(), taskHandler: { _ in } - ) + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account, options: .init(), taskHandler: { _ in }) + guard let capabilities, error == .success else { logger.error("Could not acquire capabilities during item move to trash, won't proceed.", [.item: modifiedItem, .error: error]) return (nil, error.fileProviderError) } + guard capabilities.files?.undelete == true else { logger.error("Cannot delete item as server does not support trashing.", [.item: modifiedItem]) return (nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) @@ -672,15 +672,8 @@ public extension Item { // Rename the item if necessary before doing the trashing procedures if changedFields.contains(.filename) { let currentParentItemRemotePath = modifiedItem.metadata.serverUrl - let preTrashingRenamedRemotePath = - currentParentItemRemotePath + "/" + itemTarget.filename - let (renameModifiedItem, renameError) = await modifiedItem.move( - newFileName: itemTarget.filename, - newRemotePath: preTrashingRenamedRemotePath, - newParentItemIdentifier: modifiedItem.parentItemIdentifier, - newParentItemRemotePath: currentParentItemRemotePath, - dbManager: dbManager - ) + let preTrashingRenamedRemotePath = currentParentItemRemotePath + "/" + itemTarget.filename + let (renameModifiedItem, renameError) = await modifiedItem.move(newFileName: itemTarget.filename, newRemotePath: preTrashingRenamedRemotePath, newParentItemIdentifier: modifiedItem.parentItemIdentifier, newParentItemRemotePath: currentParentItemRemotePath, dbManager: dbManager) guard renameError == nil, let renameModifiedItem else { logger.error("Could not rename pre-trash item.", [.item: modifiedItem.itemIdentifier, .error: error]) @@ -690,10 +683,12 @@ public extension Item { modifiedItem = renameModifiedItem } - let (trashedItem, trashingError) = await Self.trash( - modifiedItem, account: account, dbManager: dbManager, domain: domain, log: logger.log - ) - guard trashingError == nil else { return (modifiedItem, trashingError) } + let (trashedItem, trashingError) = await Self.trash(modifiedItem, account: account, dbManager: dbManager, domain: domain, log: logger.log) + + guard trashingError == nil else { + return (modifiedItem, trashingError) + } + modifiedItem = trashedItem } else if changedFields.contains(.filename) || changedFields.contains(.parentItemIdentifier) { // Recover the item first diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift index 63cc4c01..cfa9dfc9 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift @@ -23,16 +23,14 @@ extension Item { } let ocId = modifiedItem.itemIdentifier.rawValue + guard let dirtyMetadata = dbManager.itemMetadata(ocId: ocId) else { - logger.error( - """ - Could not correctly process trashing results, dirty metadata not found. - \(modifiedItem.filename) \(ocId) - """ - ) + logger.error("Could not correctly process trashing results, dirty metadata not found.", [.item: ocId, .name: modifiedItem.filename]) return (modifiedItem, NSFileProviderError(.cannotSynchronize)) } + let dirtyChildren = dbManager.childItems(directoryMetadata: dirtyMetadata) + let dirtyItem = await Item( metadata: dirtyMetadata, parentItemIdentifier: .trashContainer, @@ -83,7 +81,7 @@ extension Item { } } - var postDeleteMetadata = targetItemNKTrash.toItemMetadata(account: account) + var postDeleteMetadata = targetItemNKTrash.toItemMetadata(account: account, wasTrashedLocally: true) postDeleteMetadata.ocId = modifiedItem.itemIdentifier.rawValue dbManager.addItemMetadata(postDeleteMetadata) @@ -124,23 +122,21 @@ extension Item { // Update state of child files childFiles.removeFirst() // This is the target path, already scanned + for file in childFiles { var metadata = file.toItemMetadata() + guard let original = dirtyChildren .filter({ $0.ocId == metadata.ocId || $0.fileId == metadata.fileId }) .first else { - logger.info( - """ - Skipping post-trash child item metadata: \(metadata.fileName) - Could not find matching existing item in database, cannot do ocId correction - """ - ) + logger.info("Skipping post-trash child item metadata. Could not find matching existing item in database, cannot do ocId correction.", [.name: metadata.fileName]) continue } + metadata.ocId = original.ocId // Give original id back dbManager.addItemMetadata(metadata) - logger.info("Note: that was a post-trash child item metadata") + logger.info("The previous addition was a post-trash child item metadata.") } return (postDeleteItem, nil) @@ -158,7 +154,8 @@ extension Item { let logger = FileProviderLogger(category: "Item", log: log) func finaliseRestore(target: NKFile) async -> (Item, Error?) { - let restoredItemMetadata = target.toItemMetadata() + let restoredItemMetadata = target.toItemMetadata(wasTrashedLocally: false) + guard let parentItemIdentifier = await dbManager.parentItemIdentifierWithRemoteFallback( fromMetadata: restoredItemMetadata, remoteInterface: remoteInterface, @@ -175,6 +172,7 @@ extension Item { newFileName: restoredItemMetadata.fileName ) } + dbManager.addItemMetadata(restoredItemMetadata) return await (Item( @@ -194,29 +192,24 @@ extension Item { options: .init(), taskHandler: { _ in } ) + guard restoreError == .success else { - logger.error( - """ - Could not restore item \(modifiedItem.filename) from trash - Received error: \(restoreError.errorDescription) - """ - ) + logger.error("Could not restore item from trash.", [.name: modifiedItem.filename, .error: restoreError.errorDescription]) + return (modifiedItem, restoreError.fileProviderError) } + guard modifiedItem.metadata.trashbinOriginalLocation != "" else { - logger.error( - """ - Could not scan restored item \(modifiedItem.filename). - The trashed file's original location is invalid. - """ - ) + logger.error("Could not scan restored item. The trashed file's original location is invalid.", [.name: modifiedItem.filename]) + if #available(macOS 11.3, *) { return (modifiedItem, NSFileProviderError(.unsyncedEdits)) } + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) } - let originalLocation = - account.davFilesUrl + "/" + modifiedItem.metadata.trashbinOriginalLocation + + let originalLocation = account.davFilesUrl + "/" + modifiedItem.metadata.trashbinOriginalLocation let (_, files, _, enumerateError) = await modifiedItem.remoteInterface.enumerate( remotePath: originalLocation, @@ -228,41 +221,27 @@ extension Item { options: .init(), taskHandler: { _ in } ) + guard enumerateError == .success, !files.isEmpty, let target = files.first else { - logger.error( - """ - Could not scan restored state of file \(originalLocation) - Received error: \(enumerateError.errorDescription) - Files: \(files.count) - """ - ) + logger.error("Could not scan restored state of file.", [.error: enumerateError, .url: originalLocation]) + if #available(macOS 11.3, *) { return (modifiedItem, NSFileProviderError(.unsyncedEdits)) } + return (modifiedItem, enumerateError.fileProviderError) } guard target.ocId == modifiedItem.itemIdentifier.rawValue else { - logger.info( - """ - Restored item \(originalLocation) - does not match \(modifiedItem.filename) - (it is likely that when restoring from the trash, there was another identical item). - """ - ) + logger.info("Restored item at location does not match name (it is likely that when restoring from the trash, there was another identical item).", [.name: modifiedItem.filename, .url: originalLocation]) guard let finalSlashIndex = originalLocation.lastIndex(of: "/") else { return (modifiedItem, NSFileProviderError(.cannotSynchronize)) } + var parentDirectoryRemotePath = originalLocation parentDirectoryRemotePath.removeSubrange(finalSlashIndex ..< originalLocation.endIndex) - - logger.info( - """ - Scanning parent folder at \(parentDirectoryRemotePath) for current - state of item restored from trash. - """ - ) + logger.info("Scanning parent folder at \(parentDirectoryRemotePath) for current state of item restored from trash.") let (_, files, _, folderScanError) = await modifiedItem.remoteInterface.enumerate( remotePath: parentDirectoryRemotePath, @@ -294,6 +273,7 @@ extension Item { finished successfully but the target item restored from trash not found. """ ) + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index c96a0e88..01398746 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -267,7 +267,8 @@ public final class Item: NSObject, NSFileProviderItem, Sendable { uploaded: true, urlBase: "", // Placeholder as not set in original code user: "", // Placeholder as not set in original code - userId: "" // Placeholder as not set in original code + userId: "", // Placeholder as not set in original code + wasTrashedLocally: false ) return Item( metadata: metadata, @@ -310,7 +311,8 @@ public final class Item: NSObject, NSFileProviderItem, Sendable { uploaded: true, urlBase: "", // Placeholder as not set in original code user: "", // Placeholder as not set in original code - userId: "" // Placeholder as not set in original code + userId: "", // Placeholder as not set in original code + wasTrashedLocally: false ) return Item( metadata: metadata, diff --git a/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift index bf702103..0d129d34 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift @@ -96,6 +96,7 @@ public protocol ItemMetadata: Equatable { var user: String { get set } // The user who owns the file (Nextcloud username) var userId: String { get set } // The user who owns the file (backend user id) // (relevant for alt. backends like LDAP) + var wasTrashedLocally: Bool { get set } } public extension ItemMetadata { diff --git a/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift index dd63bb52..84879e78 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift @@ -103,6 +103,7 @@ class RealmItemMetadata: Object, ItemMetadata { @Persisted var user = "" // The user who owns the file (Nextcloud username) @Persisted var userId = "" // The user who owns the file (backend user id) // (relevant for alt. backends like LDAP) + @Persisted var wasTrashedLocally: Bool = false override func isEqual(_ object: Any?) -> Bool { if let object = object as? RealmItemMetadata { diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift index de81f4d4..c5117665 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift @@ -78,6 +78,14 @@ public struct SendableItemMetadata: ItemMetadata, Sendable { public var user: String public var userId: String + /// + /// Whether the item was moved to the trash container on the local device or not. + /// + /// This is `false` for items which were placed in the trash on the server or on another client and left out in the trash container content enumeration. + /// We only want locally trashed items to show up in the trash. + /// + public var wasTrashedLocally: Bool + public init( ocId: String, account: String, @@ -144,7 +152,8 @@ public struct SendableItemMetadata: ItemMetadata, Sendable { uploadDate: Date = Date(), urlBase: String, user: String, - userId: String + userId: String, + wasTrashedLocally: Bool ) { self.ocId = ocId self.account = account @@ -212,6 +221,7 @@ public struct SendableItemMetadata: ItemMetadata, Sendable { self.urlBase = urlBase self.user = user self.userId = userId + self.wasTrashedLocally = wasTrashedLocally } init(value: any ItemMetadata) { @@ -281,5 +291,6 @@ public struct SendableItemMetadata: ItemMetadata, Sendable { urlBase = value.urlBase user = value.user userId = value.userId + wasTrashedLocally = value.wasTrashedLocally } } diff --git a/Tests/Interface/ItemMetadata+Init.swift b/Tests/Interface/ItemMetadata+Init.swift index f12ca385..616f495c 100644 --- a/Tests/Interface/ItemMetadata+Init.swift +++ b/Tests/Interface/ItemMetadata+Init.swift @@ -29,7 +29,8 @@ public extension SendableItemMetadata { size: 0, urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: false ) } } diff --git a/Tests/Interface/MockRemoteItem.swift b/Tests/Interface/MockRemoteItem.swift index 98ff24f1..e1ea2b97 100644 --- a/Tests/Interface/MockRemoteItem.swift +++ b/Tests/Interface/MockRemoteItem.swift @@ -201,7 +201,8 @@ public class MockRemoteItem: Equatable { trashbinOriginalLocation: trashbinOriginalLocation ?? "", urlBase: account.serverUrl, user: account.username, - userId: account.id + userId: account.id, + wasTrashedLocally: false ) } } diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 48b79da0..6d4e85b5 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -1032,49 +1032,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { ) let observer = MockEnumerationObserver(enumerator: enumerator) try await observer.enumerateItems() - XCTAssertEqual(observer.items.count, 3) - - let storedItemAMaybe = await Item.storedItem( - identifier: .init(remoteTrashItemA.identifier), - account: Self.account, - remoteInterface: remoteInterface, - dbManager: Self.dbManager, - log: FileProviderLogMock() - ) - let storedItemA = try XCTUnwrap(storedItemAMaybe) - XCTAssertEqual(storedItemA.itemIdentifier.rawValue, remoteTrashItemA.identifier) - XCTAssertEqual(storedItemA.filename, remoteTrashItemA.name) - XCTAssertEqual(storedItemA.documentSize?.int64Value, remoteTrashItemA.size) - XCTAssertEqual(storedItemA.isDownloaded, false) - XCTAssertEqual(storedItemA.isUploaded, true) - - let storedItemBMaybe = await Item.storedItem( - identifier: .init(remoteTrashItemB.identifier), - account: Self.account, - remoteInterface: remoteInterface, - dbManager: Self.dbManager, - log: FileProviderLogMock() - ) - let storedItemB = try XCTUnwrap(storedItemBMaybe) - XCTAssertEqual(storedItemB.itemIdentifier.rawValue, remoteTrashItemB.identifier) - XCTAssertEqual(storedItemB.filename, remoteTrashItemB.name) - XCTAssertEqual(storedItemB.documentSize?.int64Value, remoteTrashItemB.size) - XCTAssertEqual(storedItemB.isDownloaded, false) - XCTAssertEqual(storedItemB.isUploaded, true) - - let storedItemCMaybe = await Item.storedItem( - identifier: .init(remoteTrashItemC.identifier), - account: Self.account, - remoteInterface: remoteInterface, - dbManager: Self.dbManager, - log: FileProviderLogMock() - ) - let storedItemC = try XCTUnwrap(storedItemCMaybe) - XCTAssertEqual(storedItemC.itemIdentifier.rawValue, remoteTrashItemC.identifier) - XCTAssertEqual(storedItemC.filename, remoteTrashItemC.name) - XCTAssertEqual(storedItemC.documentSize?.int64Value, remoteTrashItemC.size) - XCTAssertEqual(storedItemC.isDownloaded, false) - XCTAssertEqual(storedItemC.isUploaded, true) + XCTAssertEqual(observer.items.count, 0) } func testTrashChangeEnumeration() async throws { @@ -1106,43 +1064,16 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { rootTrashItem.children = [remoteTrashItemA, remoteTrashItemB] remoteTrashItemB.parent = rootTrashItem try await observer.enumerateChanges() - XCTAssertEqual(observer.changedItems.count, 1) - XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemB.identifier)) + XCTAssertEqual(observer.changedItems.count, 0) observer.reset() rootTrashItem.children = [remoteTrashItemB, remoteTrashItemC] remoteTrashItemA.parent = nil remoteTrashItemC.parent = rootTrashItem try await observer.enumerateChanges() - XCTAssertEqual(observer.changedItems.count, 1) + XCTAssertEqual(observer.changedItems.count, 0) XCTAssertEqual(observer.deletedItemIdentifiers.count, 1) XCTAssertEqual(Self.dbManager.itemMetadata(ocId: remoteTrashItemA.identifier)?.deleted, true) - XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemB.identifier)) - XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemC.identifier)) - } - - func testTrashItemEnumerationFailWhenNoTrashInCapabilities() async { - let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) - XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) - remoteInterface.capabilities = - remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") - - let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db - debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free - let enumerator = Enumerator( - enumeratedItemIdentifier: .trashContainer, - account: Self.account, - remoteInterface: remoteInterface, - dbManager: Self.dbManager, - log: FileProviderLogMock() - ) - let observer = MockEnumerationObserver(enumerator: enumerator) - do { - try await observer.enumerateItems() - XCTFail("Item enumeration should have failed!") - } catch { - XCTAssertEqual((error as NSError?)?.code, NSFeatureUnsupportedError) - } } func testKeepDownloadedRetainedDuringEnumeration() async throws { diff --git a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift index af2ddb78..e0e1d16e 100644 --- a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift @@ -41,6 +41,8 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { Task { try await server.run() } + + try await Task.sleep(nanoseconds: 500_000_000) // Add a small delay to ensure the server is fully ready } override func tearDown() async throws { @@ -238,8 +240,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { ) serverFolderA.children.append(newFileInA) - let authExpectation = - XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) + let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified") let notificationInterface = MockChangeNotificationInterface { @@ -257,9 +258,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { // 2. Act & Assert await wait(for: authExpectation, description: "authentication") - Self.notifyPushServer.send(message: "notify_file") - await wait(for: changeNotifiedExpectation, description: "change notification") // 3. Assert Database State @@ -279,20 +278,14 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { // Check deleted items let deletedFileInA = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "fileInA")) - XCTAssertTrue( - deletedFileInA.deleted, "File inside updated folder should be marked as deleted." - ) - XCTAssertTrue(deletedFileInA.syncTime >= testStartDate, - "Deleted file's sync time should be updated to current time") - XCTAssertGreaterThan(deletedFileInA.syncTime, originalFileInASyncTime, - "Deleted file's sync time should be newer than original sync time") + XCTAssertTrue(deletedFileInA.deleted, "File inside updated folder should be marked as deleted.") + XCTAssertTrue(deletedFileInA.syncTime >= testStartDate, "Deleted file's sync time should be updated to current time") + XCTAssertGreaterThan(deletedFileInA.syncTime, originalFileInASyncTime, "Deleted file's sync time should be newer than original sync time") let deletedFolderB = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderB")) XCTAssertTrue(deletedFolderB.deleted, "The entire folder should be marked as deleted.") - XCTAssertTrue(deletedFolderB.syncTime >= testStartDate, - "Deleted folder's sync time should be updated to current time") - XCTAssertGreaterThan(deletedFolderB.syncTime, originalFolderBSyncTime, - "Deleted folder's sync time should be newer than original sync time") + XCTAssertTrue(deletedFolderB.syncTime >= testStartDate, "Deleted folder's sync time should be updated to current time") + XCTAssertGreaterThan(deletedFolderB.syncTime, originalFolderBSyncTime, "Deleted folder's sync time should be newer than original sync time") } func testIgnoreNonFileNotifications() async throws {