Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.2
6.1
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.2
// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,20 @@ public extension FilesDatabaseManager {
$0.account == directoryAccount && $0.serverUrl.starts(with: directoryUrlPath)
}

// Protect items whose local data is not yet on the server from deletion when their
// parent is removed. Otherwise a moved or renamed parent would wipe unsynced children.
// TODO: the parent directory itself is still deleted here, so a protected child is
// orphaned once its upload completes. Follow up by deferring parent deletion or
// reparenting the child after the upload finishes.
for result in results {
if result.status >= Status.inUpload.rawValue {
logger.info("Skipping deletion of child with pending upload.", [.item: result.ocId])
continue
}
if result.isLockFileOfLocalOrigin {
logger.info("Skipping deletion of local origin lock file during directory delete.", [.item: result.ocId, .name: result.fileName])
continue
}
let inactiveItemMetadata = SendableItemMetadata(value: result)
do {
try database.write { result.deleted = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public final class FilesDatabaseManager: Sendable {
)
}

private static let schemaVersion = SchemaVersion.addedIsLockFileOfLocalOriginToRealmItemMetadata
private static let schemaVersion = SchemaVersion.addedWasTrashedLocallyToRealmItemMetadata
let logger: FileProviderLogger
let account: Account

Expand Down Expand Up @@ -134,6 +134,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]
)
Expand Down Expand Up @@ -520,7 +529,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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ enum SchemaVersion: UInt64 {
case deletedLocalFileMetadata = 200
case addedLockTokenPropertyToRealmItemMetadata = 201
case addedIsLockFileOfLocalOriginToRealmItemMetadata = 202
case addedWasTrashedLocallyToRealmItemMetadata = 203
}
107 changes: 20 additions & 87 deletions Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}
68 changes: 17 additions & 51 deletions Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -537,6 +492,17 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
allDeletedMetadatas = deletedMetadatas
}

// Protect items with pending uploads: their local data is not yet on the server.
allDeletedMetadatas.removeAll { deletedMetadata in
guard deletedMetadata.status >= Status.inUpload.rawValue else { return false }
logger.info("Skipping deletion of item with pending upload.", [.item: deletedMetadata.ocId])
return true
}

// Report parents before children so macOS creates a moved directory before placing its
// contents, preventing both the old and new folder name from appearing during a rename.
allUpdatedMetadatas.sort { $0.remotePath().count < $1.remotePath().count }

let allFpItemDeletionsIdentifiers = Array(
allDeletedMetadatas.map { NSFileProviderItemIdentifier($0.ocId) })
if !allFpItemDeletionsIdentifiers.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -80,15 +80,16 @@ extension NKFile {
sharePermissionsCloudMesh: sharePermissionsCloudMesh,
shareType: shareType,
size: size,
tags: tags,
tags: tags.map(\.name),
uploaded: uploaded,
trashbinFileName: trashbinFileName,
trashbinOriginalLocation: trashbinOriginalLocation,
trashbinDeletionTime: trashbinDeletionTime,
uploadDate: uploadDate as Date,
urlBase: urlBase,
user: user,
userId: userId
userId: userId,
wasTrashedLocally: wasTrashedLocally
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,7 +38,8 @@ extension NKTrash {
trashbinDeletionTime: trashbinDeletionTime,
urlBase: account.serverUrl,
user: account.username,
userId: account.id
userId: account.id,
wasTrashedLocally: wasTrashedLocally
)
}
}
Loading
Loading