From 57eef7258084ac5b74ccad941206af4be7fcc510 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 15 Sep 2026 15:37:13 +0200 Subject: [PATCH 01/21] create sourcecode Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Sources/NextcloudKit/NextcloudKit+Albums.swift diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift new file mode 100644 index 00000000..b91707e2 --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +public extension NextcloudKit { +} From 896b425fa9a48c7068852d89fcd41b0b548c94c0 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 15 Sep 2026 15:55:09 +0200 Subject: [PATCH 02/21] fetchAllAlbums Signed-off-by: Marino Faggiana --- .../Models/Albums/NKAlbumDTO.swift | 14 +++ .../Models/Albums/NKAlbumPhotoDTO.swift | 20 ++++ .../NextcloudKit/NextcloudKit+Albums.swift | 95 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift create mode 100644 Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift diff --git a/Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift b/Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift new file mode 100644 index 00000000..08bdea97 --- /dev/null +++ b/Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKAlbumDTO { + let href: String + let lastPhotoId: String? + let itemCount: Int? + let location: String? + let dateRange: String? + let collaborators: String? +} diff --git a/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift b/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift new file mode 100644 index 00000000..3423128b --- /dev/null +++ b/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKAlbumPhotoDTO { + let fileId: String + let fileName: String + let contentType: String + let contentLength: Int + let lastModified: Date + let hasPreview: Bool + let isHidden: Bool + let isFavorite: Bool + let permissions: String + let originalDateTime: Date? + let width: Int? + let height: Int? +} diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index b91707e2..d03b9a5a 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -5,6 +5,101 @@ import Foundation import Alamofire +import SwiftyJSON +import SwiftyXMLParser public extension NextcloudKit { + func fetchAllAlbums( + for account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result<[NKAlbumDTO], Error>) -> Void + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + ), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + completion(.failure(NKError.urlError)) + } + } + let method = HTTPMethod(rawValue: "PROPFIND") + let propfindXML = """ + + + + + + + + + + + """ + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = propfindXML.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + guard let data = response.data else { + return options.queue.async { + completion(.failure(NKError.invalidData)) + } + } + + let albums = self.parseAlbumsXML(data: data) + completion(.success(albums)) + } + } + } + + private func parseAlbumsXML(data: Data) -> [NKAlbumDTO] { + let xml = XML.parse(data) + var albums: [NKAlbumDTO] = [] + let elements = xml["d:multistatus", "d:response"] + + for element in elements { + let href = element["d:href"].element?.text ?? "" + let prop = element["d:propstat"]["d:prop"] + let lastPhoto = prop["nc:last-photo"].element?.text + let nbItems = prop["nc:nbItems"].element?.text.flatMap { Int($0) } + let location = prop["nc:location"].element?.text + let dateRange = prop["nc:dateRange"].element?.text + let collaborators = prop["nc:collaborators"].element?.text + + // Optionally skip entries with 404 status + let status = element["d:propstat"]["d:status"].element?.text ?? "" + if status.contains("200") { + let album = NKAlbumDTO( + href: href, + lastPhotoId: lastPhoto, + itemCount: nbItems, + location: location, + dateRange: dateRange, + collaborators: collaborators + ) + albums.append(album) + } + } + + return albums + } + } From a053832928e76d981ab83505c67d2d1bcf628520 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 15 Sep 2026 16:31:04 +0200 Subject: [PATCH 03/21] wip Signed-off-by: Marino Faggiana --- .../NextcloudKit/NextcloudKit+Albums.swift | 263 +++++++++++++++++- 1 file changed, 256 insertions(+), 7 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index d03b9a5a..e3ca52b5 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -21,9 +21,7 @@ public extension NextcloudKit { endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" ), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return options.queue.async { - completion(.failure(NKError.urlError)) - } + return completion(.failure(NKError.urlError)) } let method = HTTPMethod(rawValue: "PROPFIND") let propfindXML = """ @@ -45,17 +43,143 @@ public extension NextcloudKit { urlRequest.httpBody = propfindXML.data(using: .utf8) urlRequest.timeoutInterval = options.timeout } catch { - return options.queue.async { completion(.failure(NKError(error: error))) } + return completion(.failure(NKError(error: error))) + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + // Explicit 404 check + if response.response?.statusCode == 404 { + return completion(.success([])) + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + completion(.failure(error)) + + case .success: + guard let data = response.data else { + return completion(.failure(NKError.invalidData)) + } + + let albums = self.parseAlbumsXML(data: data) + completion(.success(albums)) + } + } + } + + func createNewAlbum( + for account: String, + albumName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/\(albumName)/" + ), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return completion(.failure(NKError.urlError)) + } + let method = HTTPMethod(rawValue: "MKCOL") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return completion(.failure(NKError(error: error))) + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + // Explicit 405 check -> treat as conflict (album already exists) + if let statusCode = response.response?.statusCode, statusCode == 405 { + // Resolve localized message so UI using `localizedDescription` shows the proper text + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + // Build an NSError carrying the localized description + let nsError = NSError(domain: "NextcloudKit", code: statusCode, userInfo: [NSLocalizedDescriptionKey: message]) + return completion(.failure(NKError(error: nsError))) + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + completion(.failure(error)) + + case .success: + completion(.success(true)) + } + } + } + + func fetchAlbumPhotos( + for album: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result<[NKAlbumPhotoDTO], Error>) -> Void + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + album + "/" + ), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return completion(.failure(NKError.urlError)) + } + let method = HTTPMethod(rawValue: "PROPFIND") + + let propfindXML = """ + + + + + + + + + + + + + + + + + + """ + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = propfindXML.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return completion(.failure(NKError(error: error))) } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + // Explicit 404 check + if response.response?.statusCode == 404 { + return completion(.success([])) + } + switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { completion(.failure(error)) } + completion(.failure(error)) case .success: guard let data = response.data else { @@ -64,12 +188,75 @@ public extension NextcloudKit { } } - let albums = self.parseAlbumsXML(data: data) - completion(.success(albums)) + let photos = self.parseAlbumPhotosXML(data: data) + options.queue.async { + completion(.success(photos)) + } + } + } + + func copyPhotoToAlbum( + account: String, + sourcePath: String, + albumName: String, + fileName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return completion(.failure(NKError.urlError)) + } + let destinationPath = "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + albumName + "/" + fileName + let sourceUrlString: String = { + if sourcePath.lowercased().hasPrefix("http") { + return sourcePath + } else { + return nkSession.urlBase + sourcePath + } + }() + guard let sourceUrl = sourceUrlString.encodedToUrl else { + return completion(.failure(NKError.urlError)) + } + + headers.add( + name: "Destination", + value: destinationPath.urlEncoded ?? destinationPath + ) + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + let statusCode = response.response?.statusCode + // Explicit 404 check + if statusCode == 404 || statusCode == 403 { + return completion(.success(())) + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + completion(.failure(error)) + + case .success: + completion(.success(())) + } } } } + // MARK: - Helper + private func parseAlbumsXML(data: Data) -> [NKAlbumDTO] { let xml = XML.parse(data) var albums: [NKAlbumDTO] = [] @@ -102,4 +289,66 @@ public extension NextcloudKit { return albums } + private func parseAlbumPhotosXML(data: Data) -> [NKAlbumPhotoDTO] { + let xml = XML.parse(data) + var photos: [NKAlbumPhotoDTO] = [] + let elements = xml["d:multistatus", "d:response"] + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + + for element in elements { + let href = element["d:href"].element?.text ?? "" + let fileName = URL(string: href)?.lastPathComponent ?? href + let propstats: [XML.Element] = element["d:propstat"].all ?? [] + + for propstat in propstats { + let ps = XML.Accessor(propstat) + let status = ps["d:status"].element?.text ?? "" + guard status.contains("200") else { continue } + let prop = ps["d:prop"] + + guard let fileId = prop["oc:fileid"].element?.text else { continue } + + let contentType = prop["d:getcontenttype"].element?.text ?? "" + let contentLength = prop["d:getcontentlength"].element?.text.flatMap { Int($0) } ?? 0 + let lastModified = prop["d:getlastmodified"].element?.text.flatMap { + formatter.date(from: $0) + } ?? Date() + + let hasPreview = prop["nc:has-preview"].element?.text == "true" + let isHidden = prop["nc:hidden"].element?.text == "true" + let isFavorite = prop["oc:favorite"].element?.text == "1" + let permissions = prop["oc:permissions"].element?.text ?? "" + + let originalDateTime = prop["nc:metadata-photos-original_date_time"] + .element?.text.flatMap { Double($0) } + .flatMap { Date(timeIntervalSince1970: $0) } + + let sizeNode = prop["nc:metadata-photos-size"] + let width = sizeNode["width"].element?.text.flatMap { Int($0) } + let height = sizeNode["height"].element?.text.flatMap { Int($0) } + + let photo = NKAlbumPhotoDTO( + fileId: fileId, + fileName: fileName, + contentType: contentType, + contentLength: contentLength, + lastModified: lastModified, + hasPreview: hasPreview, + isHidden: isHidden, + isFavorite: isFavorite, + permissions: permissions, + originalDateTime: originalDateTime, + width: width, + height: height + ) + + photos.append(photo) + } + } + + return photos + } } From db1bfcf58cebdd68c7ec7279c407b4999583b9de Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 16 Sep 2026 08:42:36 +0200 Subject: [PATCH 04/21] wip Signed-off-by: Marino Faggiana --- .../Models/Albums/NKAlbumPhotoDTO.swift | 20 -- .../NKAlbumDTO.swift => NKPhotoAlbum.swift} | 5 +- .../NextcloudKit/NextcloudKit+Albums.swift | 269 +++++++++--------- 3 files changed, 134 insertions(+), 160 deletions(-) delete mode 100644 Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift rename Sources/NextcloudKit/Models/{Albums/NKAlbumDTO.swift => NKPhotoAlbum.swift} (72%) diff --git a/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift b/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift deleted file mode 100644 index 3423128b..00000000 --- a/Sources/NextcloudKit/Models/Albums/NKAlbumPhotoDTO.swift +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Dhanesh -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation - -public struct NKAlbumPhotoDTO { - let fileId: String - let fileName: String - let contentType: String - let contentLength: Int - let lastModified: Date - let hasPreview: Bool - let isHidden: Bool - let isFavorite: Bool - let permissions: String - let originalDateTime: Date? - let width: Int? - let height: Int? -} diff --git a/Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift similarity index 72% rename from Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift rename to Sources/NextcloudKit/Models/NKPhotoAlbum.swift index 08bdea97..3d9f6762 100644 --- a/Sources/NextcloudKit/Models/Albums/NKAlbumDTO.swift +++ b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana // SPDX-License-Identifier: GPL-3.0-or-later import Foundation -public struct NKAlbumDTO { +public struct NKPhotoAlbum { + let account: String let href: String let lastPhotoId: String? let itemCount: Int? diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index e3ca52b5..f8e4ed84 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -13,8 +13,7 @@ public extension NextcloudKit { for account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result<[NKAlbumDTO], Error>) -> Void - ) { + completion: @escaping (Result<[NKPhotoAlbum], Error>) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, @@ -23,7 +22,6 @@ public extension NextcloudKit { let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { return completion(.failure(NKError.urlError)) } - let method = HTTPMethod(rawValue: "PROPFIND") let propfindXML = """ @@ -36,10 +34,10 @@ public extension NextcloudKit { """ - var urlRequest: URLRequest + do { - try urlRequest = URLRequest(url: url, method: method, headers: headers) + try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "PROPFIND"), headers: headers) urlRequest.httpBody = propfindXML.data(using: .utf8) urlRequest.timeoutInterval = options.timeout } catch { @@ -50,11 +48,6 @@ public extension NextcloudKit { task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - // Explicit 404 check - if response.response?.statusCode == 404 { - return completion(.success([])) - } - switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) @@ -65,7 +58,7 @@ public extension NextcloudKit { return completion(.failure(NKError.invalidData)) } - let albums = self.parseAlbumsXML(data: data) + let albums = self.parseAlbumsXML(account: account, data: data) completion(.success(albums)) } } @@ -76,8 +69,7 @@ public extension NextcloudKit { albumName: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void - ) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, @@ -86,11 +78,10 @@ public extension NextcloudKit { let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { return completion(.failure(NKError.urlError)) } - let method = HTTPMethod(rawValue: "MKCOL") - var urlRequest: URLRequest + do { - try urlRequest = URLRequest(url: url, method: method, headers: headers) + try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "MKCOL"), headers: headers) urlRequest.timeoutInterval = options.timeout } catch { return completion(.failure(NKError(error: error))) @@ -100,22 +91,13 @@ public extension NextcloudKit { task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - // Explicit 405 check -> treat as conflict (album already exists) - if let statusCode = response.response?.statusCode, statusCode == 405 { - // Resolve localized message so UI using `localizedDescription` shows the proper text - let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") - // Build an NSError carrying the localized description - let nsError = NSError(domain: "NextcloudKit", code: statusCode, userInfo: [NSLocalizedDescriptionKey: message]) - return completion(.failure(NKError(error: nsError))) - } - switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) completion(.failure(error)) case .success: - completion(.success(true)) + completion(.success(account)) } } } @@ -125,43 +107,21 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result<[NKAlbumPhotoDTO], Error>) -> Void + completion: @escaping (Result<[NKFile], Error>) -> Void ) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + album + "/" ), - let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/xml", accept: "application/xml") else { return completion(.failure(NKError.urlError)) } - let method = HTTPMethod(rawValue: "PROPFIND") - - let propfindXML = """ - - - - - - - - - - - - - - - - - - """ - var urlRequest: URLRequest + do { - try urlRequest = URLRequest(url: url, method: method, headers: headers) - urlRequest.httpBody = propfindXML.data(using: .utf8) + try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "PROPFIND"), headers: headers) + urlRequest.httpBody = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).getRequestBodyFile(createProperties: options.createProperties, removeProperties: options.removeProperties).data(using: .utf8) urlRequest.timeoutInterval = options.timeout } catch { return completion(.failure(NKError(error: error))) @@ -171,11 +131,6 @@ public extension NextcloudKit { task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - // Explicit 404 check - if response.response?.statusCode == 404 { - return completion(.success([])) - } - switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) @@ -187,10 +142,9 @@ public extension NextcloudKit { completion(.failure(NKError.invalidData)) } } - - let photos = self.parseAlbumPhotosXML(data: data) - options.queue.async { - completion(.success(photos)) + Task { + let files = await NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataFile(xmlData: data, nkSession: nkSession, rootFileName: self.nkCommonInstance.rootFileName, showHiddenFiles: true, includeHiddenFiles: []) + completion(.success(files)) } } } @@ -202,8 +156,7 @@ public extension NextcloudKit { fileName: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void - ) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { return completion(.failure(NKError.urlError)) @@ -230,36 +183,138 @@ public extension NextcloudKit { try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers) urlRequest.timeoutInterval = options.timeout } catch { - return options.queue.async { completion(.failure(NKError(error: error))) } + return completion(.failure(NKError(error: error))) } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - let statusCode = response.response?.statusCode - // Explicit 404 check - if statusCode == 404 || statusCode == 403 { - return completion(.success(())) - } - switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) completion(.failure(error)) case .success: - completion(.success(())) + completion(.success((account))) } } } } + func deletePhotoFromAlbum(albumName: String, + fileName: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + albumName + "/" + fileName + ), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return completion(.failure(NKError.urlError)) + } + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .delete, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return completion(.failure(NKError(error: error))) + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + completion(.failure(error)) + + case .success: + completion(.success((account))) + } + } + } + + func deletePhotoFromAlbumAsync( + albumName: String, + fileName: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + deletePhotoFromAlbum( + albumName: albumName, + fileName: fileName, + account: account, + options: options, + taskHandler: taskHandler + ) { result in + continuation.resume(with: result) + } + } + } + + func renameAlbum( + account: String, + from name: String, + to newName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + name + "/" + ), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return completion(.failure(NKError.urlError)) + } + let destinationHeader = "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + newName + "/" + + // Add the required MOVE header + headers.add( + name: "Destination", + value: destinationHeader.addingPercentEncoding( + withAllowedCharacters: CharacterSet.urlQueryAllowed.subtracting(["+", "?", "&"]) + ) ?? destinationHeader + ) + // Disallow overwriting an existing destination to avoid silent data loss + headers.add(name: "Overwrite", value: "F") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return completion(.failure(NKError(error: error))) + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + completion(.failure(error)) + + case .success: + completion(.success((account))) + } + } + } + // MARK: - Helper - private func parseAlbumsXML(data: Data) -> [NKAlbumDTO] { + private func parseAlbumsXML(account: String, data: Data) -> [NKPhotoAlbum] { let xml = XML.parse(data) - var albums: [NKAlbumDTO] = [] + var albums: [NKPhotoAlbum] = [] let elements = xml["d:multistatus", "d:response"] for element in elements { @@ -274,7 +329,8 @@ public extension NextcloudKit { // Optionally skip entries with 404 status let status = element["d:propstat"]["d:status"].element?.text ?? "" if status.contains("200") { - let album = NKAlbumDTO( + let album = NKPhotoAlbum( + account: account, href: href, lastPhotoId: lastPhoto, itemCount: nbItems, @@ -288,67 +344,4 @@ public extension NextcloudKit { return albums } - - private func parseAlbumPhotosXML(data: Data) -> [NKAlbumPhotoDTO] { - let xml = XML.parse(data) - var photos: [NKAlbumPhotoDTO] = [] - let elements = xml["d:multistatus", "d:response"] - - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - - for element in elements { - let href = element["d:href"].element?.text ?? "" - let fileName = URL(string: href)?.lastPathComponent ?? href - let propstats: [XML.Element] = element["d:propstat"].all ?? [] - - for propstat in propstats { - let ps = XML.Accessor(propstat) - let status = ps["d:status"].element?.text ?? "" - guard status.contains("200") else { continue } - let prop = ps["d:prop"] - - guard let fileId = prop["oc:fileid"].element?.text else { continue } - - let contentType = prop["d:getcontenttype"].element?.text ?? "" - let contentLength = prop["d:getcontentlength"].element?.text.flatMap { Int($0) } ?? 0 - let lastModified = prop["d:getlastmodified"].element?.text.flatMap { - formatter.date(from: $0) - } ?? Date() - - let hasPreview = prop["nc:has-preview"].element?.text == "true" - let isHidden = prop["nc:hidden"].element?.text == "true" - let isFavorite = prop["oc:favorite"].element?.text == "1" - let permissions = prop["oc:permissions"].element?.text ?? "" - - let originalDateTime = prop["nc:metadata-photos-original_date_time"] - .element?.text.flatMap { Double($0) } - .flatMap { Date(timeIntervalSince1970: $0) } - - let sizeNode = prop["nc:metadata-photos-size"] - let width = sizeNode["width"].element?.text.flatMap { Int($0) } - let height = sizeNode["height"].element?.text.flatMap { Int($0) } - - let photo = NKAlbumPhotoDTO( - fileId: fileId, - fileName: fileName, - contentType: contentType, - contentLength: contentLength, - lastModified: lastModified, - hasPreview: hasPreview, - isHidden: isHidden, - isFavorite: isFavorite, - permissions: permissions, - originalDateTime: originalDateTime, - width: width, - height: height - ) - - photos.append(photo) - } - } - - return photos - } } From b6ae24370e8282243f56bf7fbb94e755a12e8836 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 16 Sep 2026 08:54:01 +0200 Subject: [PATCH 05/21] wip Signed-off-by: Marino Faggiana --- .../NextcloudKit/Models/NKPhotoAlbum.swift | 14 +- .../NextcloudKit/NextcloudKit+Albums.swift | 188 ++++++++++-------- 2 files changed, 111 insertions(+), 91 deletions(-) diff --git a/Sources/NextcloudKit/Models/NKPhotoAlbum.swift b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift index 3d9f6762..aefdd037 100644 --- a/Sources/NextcloudKit/Models/NKPhotoAlbum.swift +++ b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift @@ -5,11 +5,11 @@ import Foundation public struct NKPhotoAlbum { - let account: String - let href: String - let lastPhotoId: String? - let itemCount: Int? - let location: String? - let dateRange: String? - let collaborators: String? + public let account: String + public let href: String + public let lastPhotoId: String? + public let itemCount: Int? + public let location: String? + public let dateRange: String? + public let collaborators: String? } diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index f8e4ed84..c3f02a0f 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -15,12 +15,13 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result<[NKPhotoAlbum], Error>) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, - endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + endpoint: endpoint ), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(.failure(NKError.urlError)) + return options.queue.async { completion(.failure(NKError.urlError)) } } let propfindXML = """ @@ -41,7 +42,7 @@ public extension NextcloudKit { urlRequest.httpBody = propfindXML.data(using: .utf8) urlRequest.timeoutInterval = options.timeout } catch { - return completion(.failure(NKError(error: error))) + return options.queue.async { completion(.failure(NKError(error: error))) } } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in @@ -51,15 +52,15 @@ public extension NextcloudKit { switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) + options.queue.async { completion(.failure(error)) } case .success: guard let data = response.data else { - return completion(.failure(NKError.invalidData)) + return options.queue.async { completion(.failure(NKError.invalidData)) } } let albums = self.parseAlbumsXML(account: account, data: data) - completion(.success(albums)) + options.queue.async { completion(.success(albums)) } } } } @@ -71,12 +72,13 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, - endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/\(albumName)/" + endpoint: endpoint ), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(.failure(NKError.urlError)) + return options.queue.async { completion(.failure(NKError.urlError)) } } var urlRequest: URLRequest @@ -84,7 +86,7 @@ public extension NextcloudKit { try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "MKCOL"), headers: headers) urlRequest.timeoutInterval = options.timeout } catch { - return completion(.failure(NKError(error: error))) + return options.queue.async { completion(.failure(NKError(error: error))) } } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in @@ -94,10 +96,10 @@ public extension NextcloudKit { switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) + options.queue.async { completion(.failure(error)) } case .success: - completion(.success(account)) + options.queue.async { completion(.success(account)) } } } } @@ -107,15 +109,15 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result<[NKFile], Error>) -> Void - ) { + completion: @escaping (Result<[NKFile], Error>) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: album), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, - endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + album + "/" + endpoint: endpoint ), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/xml", accept: "application/xml") else { - return completion(.failure(NKError.urlError)) + return options.queue.async { completion(.failure(NKError.urlError)) } } var urlRequest: URLRequest @@ -124,7 +126,7 @@ public extension NextcloudKit { urlRequest.httpBody = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).getRequestBodyFile(createProperties: options.createProperties, removeProperties: options.removeProperties).data(using: .utf8) urlRequest.timeoutInterval = options.timeout } catch { - return completion(.failure(NKError(error: error))) + return options.queue.async { completion(.failure(NKError(error: error))) } } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in @@ -134,70 +136,72 @@ public extension NextcloudKit { switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) + options.queue.async { completion(.failure(error)) } case .success: guard let data = response.data else { - return options.queue.async { - completion(.failure(NKError.invalidData)) - } + return options.queue.async { completion(.failure(NKError.invalidData)) } } Task { let files = await NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataFile(xmlData: data, nkSession: nkSession, rootFileName: self.nkCommonInstance.rootFileName, showHiddenFiles: true, includeHiddenFiles: []) - completion(.success(files)) + options.queue.async { completion(.success(files)) } } } } + } - func copyPhotoToAlbum( - account: String, - sourcePath: String, - albumName: String, - fileName: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(.failure(NKError.urlError)) - } - let destinationPath = "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + albumName + "/" + fileName - let sourceUrlString: String = { - if sourcePath.lowercased().hasPrefix("http") { - return sourcePath - } else { - return nkSession.urlBase + sourcePath - } - }() - guard let sourceUrl = sourceUrlString.encodedToUrl else { - return completion(.failure(NKError.urlError)) + func copyPhotoToAlbum( + account: String, + sourcePath: String, + albumName: String, + fileName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName), + let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint), + let destination = try? destinationUrl.asURL() else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + let sourceUrlString: String = { + if sourcePath.lowercased().hasPrefix("http") { + return sourcePath + } else { + return nkSession.urlBase + sourcePath } + }() + guard let sourceUrl = sourceUrlString.encodedToUrl else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } - headers.add( - name: "Destination", - value: destinationPath.urlEncoded ?? destinationPath - ) - - var urlRequest: URLRequest - do { - try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers) - urlRequest.timeoutInterval = options.timeout - } catch { - return completion(.failure(NKError(error: error))) - } + headers.add( + name: "Destination", + value: destination.absoluteString + ) - nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) - - case .success: - completion(.success((account))) - } + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { completion(.success((account))) } } } } @@ -209,12 +213,13 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, - endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + albumName + "/" + fileName + endpoint: endpoint ), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(.failure(NKError.urlError)) + return options.queue.async { completion(.failure(NKError.urlError)) } } var urlRequest: URLRequest @@ -222,7 +227,7 @@ public extension NextcloudKit { try urlRequest = URLRequest(url: url, method: .delete, headers: headers) urlRequest.timeoutInterval = options.timeout } catch { - return completion(.failure(NKError(error: error))) + return options.queue.async { completion(.failure(NKError(error: error))) } } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in @@ -232,10 +237,10 @@ public extension NextcloudKit { switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) + options.queue.async { completion(.failure(error)) } case .success: - completion(.success((account))) + options.queue.async { completion(.success((account))) } } } } @@ -268,22 +273,22 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: name), let url = nkCommonInstance.createStandardUrl( serverUrl: nkSession.urlBase, - endpoint: nkSession.urlBase + "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + name + "/" + endpoint: endpoint ), var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(.failure(NKError.urlError)) + return options.queue.async { completion(.failure(NKError.urlError)) } + } + guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: newName), + let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint), + let destination = try? destinationUrl.asURL() else { + return options.queue.async { completion(.failure(NKError.urlError)) } } - let destinationHeader = "/remote.php/dav/photos/" + nkSession.userId + "/albums/" + newName + "/" // Add the required MOVE header - headers.add( - name: "Destination", - value: destinationHeader.addingPercentEncoding( - withAllowedCharacters: CharacterSet.urlQueryAllowed.subtracting(["+", "?", "&"]) - ) ?? destinationHeader - ) + headers.add(name: "Destination", value: destination.absoluteString) // Disallow overwriting an existing destination to avoid silent data loss headers.add(name: "Overwrite", value: "F") @@ -292,7 +297,7 @@ public extension NextcloudKit { try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers) urlRequest.timeoutInterval = options.timeout } catch { - return completion(.failure(NKError(error: error))) + return options.queue.async { completion(.failure(NKError(error: error))) } } nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in @@ -302,16 +307,31 @@ public extension NextcloudKit { switch response.result { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) - completion(.failure(error)) + options.queue.async { completion(.failure(error)) } case .success: - completion(.success((account))) + options.queue.async { completion(.success((account))) } } } } // MARK: - Helper + // Encode each raw path component once, including literal percent signs and slashes. + private func albumEndpoint(userId: String, albumName: String? = nil, fileName: String? = nil) -> String? { + let allowedCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + var components = ["remote.php", "dav", "photos", userId, "albums"] + if let albumName { components.append(albumName) } + if let fileName { components.append(fileName) } + + var encodedComponents: [String] = [] + for component in components { + guard let encoded = component.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { return nil } + encodedComponents.append(encoded) + } + return encodedComponents.joined(separator: "/") + (fileName == nil ? "/" : "") + } + private func parseAlbumsXML(account: String, data: Data) -> [NKPhotoAlbum] { let xml = XML.parse(data) var albums: [NKPhotoAlbum] = [] From a37c8e590239105549fbbeabb437397fcf276c54 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 16 Sep 2026 16:20:11 +0200 Subject: [PATCH 06/21] wip Signed-off-by: Marino Faggiana --- .../NextcloudKit/Models/NKPhotoAlbum.swift | 42 ++++++++++++++++++- .../NKPhotoAlbumTests.swift | 40 ++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift diff --git a/Sources/NextcloudKit/Models/NKPhotoAlbum.swift b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift index aefdd037..481c4105 100644 --- a/Sources/NextcloudKit/Models/NKPhotoAlbum.swift +++ b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift @@ -4,7 +4,7 @@ import Foundation -public struct NKPhotoAlbum { +public struct NKPhotoAlbum: Identifiable, Hashable, Sendable { public let account: String public let href: String public let lastPhotoId: String? @@ -12,4 +12,44 @@ public struct NKPhotoAlbum { public let location: String? public let dateRange: String? public let collaborators: String? + + /// Stable identity scoped to the account, independent of album metadata. + public var id: String { "\(account.utf8.count):\(account)\(href)" } + + /// The album name decoded from the last component of its DAV path. + public var name: String { + guard let component = href.split(separator: "/").last else { return href } + return String(component).removingPercentEncoding ?? String(component) + } + + /// Dates supplied by the server, or nil when the date range is missing or invalid. + public let startDate: Date? + public let endDate: Date? + + public init(account: String, + href: String, + lastPhotoId: String? = nil, + itemCount: Int? = nil, + location: String? = nil, + dateRange: String? = nil, + collaborators: String? = nil) { + self.account = account + self.href = href + self.lastPhotoId = lastPhotoId + self.itemCount = itemCount + self.location = location + self.dateRange = dateRange + self.collaborators = collaborators + + if let data = dateRange?.data(using: .utf8), + let range = try? JSONDecoder().decode([String: TimeInterval].self, from: data), + let start = range["start"], let end = range["end"], + start.isFinite, end.isFinite, start <= end { + self.startDate = Date(timeIntervalSince1970: start) + self.endDate = Date(timeIntervalSince1970: end) + } else { + self.startDate = nil + self.endDate = nil + } + } } diff --git a/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift b/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift new file mode 100644 index 00000000..05a5ffcd --- /dev/null +++ b/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +import NextcloudKit + +struct NKPhotoAlbumTests { + @Test func identityIsScopedToAccountAndStableAcrossMetadataChanges() { + let album = NKPhotoAlbum(account: "a", href: "/albums/one/") + let updated = NKPhotoAlbum(account: "a", href: album.href, itemCount: 3) + let otherAccount = NKPhotoAlbum(account: "b", href: album.href) + #expect(album.id == updated.id) + #expect(album.id != otherAccount.id) + #expect(Set([album, album]).count == 1) + } + + @Test func nameDecodesOnlyTheLastPathComponent() { + let album = NKPhotoAlbum(account: "a", href: "/albums/Caff%C3%A8%20%23%20100%25%2Festate/") + #expect(album.name == "Caffè # 100%/estate") + #expect(NKPhotoAlbum(account: "a", href: "/albums/100%/").name == "100%") + #expect(NKPhotoAlbum(account: "a", href: "").name.isEmpty) + } + + @Test func dateRangeUsesUnixSecondsAndPreservesRawValue() { + let raw = #"{"start":1700000000,"end":1700003600}"# + let album = NKPhotoAlbum(account: "a", href: "/albums/one/", dateRange: raw) + #expect(album.startDate == Date(timeIntervalSince1970: 1700000000)) + #expect(album.endDate == Date(timeIntervalSince1970: 1700003600)) + #expect(album.dateRange == raw) + } + + @Test(arguments: [nil, "", "invalid", "{}", #"{"start":1}"#, #"{"start":2,"end":1}"#, #"{"start":"1","end":2}"#] as [String?]) + func invalidDateRangesHaveNoDates(raw: String?) { + let album = NKPhotoAlbum(account: "a", href: "/albums/one/", dateRange: raw) + #expect(album.startDate == nil) + #expect(album.endDate == nil) + } +} From 3eccea25d66813661212b338f2b0f6cf3f7ab29a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 16 Sep 2026 16:34:36 +0200 Subject: [PATCH 07/21] wip Signed-off-by: Marino Faggiana --- .../NextcloudKit/NextcloudKit+Albums.swift | 145 ++++++++++++------ 1 file changed, 95 insertions(+), 50 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index c3f02a0f..9c9f88a6 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -9,6 +9,9 @@ import SwiftyJSON import SwiftyXMLParser public extension NextcloudKit { + + // MARK: - Album + func fetchAllAlbums( for account: String, options: NKRequestOptions = NKRequestOptions(), @@ -104,6 +107,98 @@ public extension NextcloudKit { } } + func renameAlbum( + account: String, + from name: String, + to newName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: name), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: endpoint + ), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: newName), + let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint), + let destination = try? destinationUrl.asURL() else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + // Add the required MOVE header + headers.add(name: "Destination", value: destination.absoluteString) + // Disallow overwriting an existing destination to avoid silent data loss + headers.add(name: "Overwrite", value: "F") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { completion(.success((account))) } + } + } + } + + func deleteAlbum( + albumName: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void) { + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName), + let url = nkCommonInstance.createStandardUrl( + serverUrl: nkSession.urlBase, + endpoint: endpoint + ), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .delete, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { completion(.success((account))) } + } + } + } + + // MARK: - Album Photo + func fetchAlbumPhotos( for album: String, account: String, @@ -265,56 +360,6 @@ public extension NextcloudKit { } } - func renameAlbum( - account: String, - from name: String, - to newName: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - let endpoint = albumEndpoint(userId: nkSession.userId, albumName: name), - let url = nkCommonInstance.createStandardUrl( - serverUrl: nkSession.urlBase, - endpoint: endpoint - ), - var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return options.queue.async { completion(.failure(NKError.urlError)) } - } - guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: newName), - let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint), - let destination = try? destinationUrl.asURL() else { - return options.queue.async { completion(.failure(NKError.urlError)) } - } - - // Add the required MOVE header - headers.add(name: "Destination", value: destination.absoluteString) - // Disallow overwriting an existing destination to avoid silent data loss - headers.add(name: "Overwrite", value: "F") - - var urlRequest: URLRequest - do { - try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers) - urlRequest.timeoutInterval = options.timeout - } catch { - return options.queue.async { completion(.failure(NKError(error: error))) } - } - - nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { completion(.failure(error)) } - - case .success: - options.queue.async { completion(.success((account))) } - } - } - } - // MARK: - Helper // Encode each raw path component once, including literal percent signs and slashes. From 8aad52b533ed3ae7e4eaffa0c01ace8ba132a6ca Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:17:47 +0200 Subject: [PATCH 08/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 9c9f88a6..4b3ddfb1 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -16,7 +16,7 @@ public extension NextcloudKit { for account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result<[NKPhotoAlbum], Error>) -> Void) { + completion: @escaping (Result<[NKPhotoAlbum], NKError>) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId), let url = nkCommonInstance.createStandardUrl( @@ -113,7 +113,7 @@ public extension NextcloudKit { to newName: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId, albumName: name), let url = nkCommonInstance.createStandardUrl( @@ -162,7 +162,7 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName), @@ -204,7 +204,7 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result<[NKFile], Error>) -> Void) { + completion: @escaping (Result<[NKFile], NKError>) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId, albumName: album), let url = nkCommonInstance.createStandardUrl( @@ -252,7 +252,7 @@ public extension NextcloudKit { fileName: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { return options.queue.async { completion(.failure(NKError.urlError)) } @@ -306,7 +306,7 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName), let url = nkCommonInstance.createStandardUrl( From 5a2ecf3421e4111dd19a357b509bdc122c323413 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:26:19 +0200 Subject: [PATCH 09/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 4b3ddfb1..50fb58ad 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -290,13 +290,11 @@ public extension NextcloudKit { task.taskDescription = options.taskDescription taskHandler(task) }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - - case .success: - options.queue.async { completion(.success((account))) } + } else { + options.queue.async { completion(.success(account)) } } } } From c0fd2a43a0049e1ff82b15b7576d5e5cbe82acda Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:30:43 +0200 Subject: [PATCH 10/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 50fb58ad..b809d4a8 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -289,7 +289,7 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } From e2b152f92b981d6cdda3c383f2226a9dabc9e887 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:35:41 +0200 Subject: [PATCH 11/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index b809d4a8..109b9ee8 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -326,14 +326,12 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - - case .success: - options.queue.async { completion(.success((account))) } + } else { + options.queue.async { completion(.success(account)) } } } } From cb15e342984299d948019eba36f25972e875b165 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:46:55 +0200 Subject: [PATCH 12/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 109b9ee8..ae952b9a 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -95,13 +95,11 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - - case .success: + } else { options.queue.async { completion(.success(account)) } } } From 04ef5b0cdef2c9fc5457eddee76a0afdbe234d43 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:49:53 +0200 Subject: [PATCH 13/21] wip Signed-off-by: Marino Faggiana --- .../NextcloudKit/NextcloudKit+Albums.swift | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index ae952b9a..814d8e60 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -56,12 +56,10 @@ public extension NextcloudKit { case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - case .success: guard let data = response.data else { return options.queue.async { completion(.failure(NKError.invalidData)) } } - let albums = self.parseAlbumsXML(account: account, data: data) options.queue.async { completion(.success(albums)) } } @@ -143,14 +141,12 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - - case .success: - options.queue.async { completion(.success((account))) } + } else { + options.queue.async { completion(.success(account)) } } } } @@ -183,14 +179,12 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - - case .success: - options.queue.async { completion(.success((account))) } + } else { + options.queue.async { completion(.success(account)) } } } } From 4b7c3dedc2b7cd9e5d753be26cef8194b0ad24b8 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 17 Sep 2026 11:54:42 +0200 Subject: [PATCH 14/21] wip Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 814d8e60..c41545d5 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -141,12 +141,13 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.response(queue: self.nkCommonInstance.backgroundQueue) { response in - if let error = response.error { + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - } else { - options.queue.async { completion(.success(account)) } + case .success: + options.queue.async { completion(.success((account))) } } } } From 7362b3ee83d9ece5fc818e4047b875c57943191d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 09:43:50 +0200 Subject: [PATCH 15/21] fix Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Upload.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Upload.swift b/Sources/NextcloudKit/NextcloudKit+Upload.swift index da5fddf5..a84d13c1 100644 --- a/Sources/NextcloudKit/NextcloudKit+Upload.swift +++ b/Sources/NextcloudKit/NextcloudKit+Upload.swift @@ -311,9 +311,12 @@ public extension NextcloudKit { // Notify start upload uploadStart(chunkedFiles) - // Global progress baseline (bytes of fully uploaded chunks) - var uploadedSoFar: Int64 = 0 - uploadProgressHandler(totalFileSize, 0, totalFileSize > 0 ? 0.0 : 1.0) + // Remaining chunks have cumulative sizes, recomputed from disk by chunkedFile. + // Include completed chunks when resuming; the interrupted chunk is sent again. + let remainingBytes = chunkedFiles.last?.size ?? 0 + var uploadedSoFar = max(0, totalFileSize - remainingBytes) + let initialFraction = totalFileSize > 0 ? Double(uploadedSoFar) / Double(totalFileSize) : 1.0 + uploadProgressHandler(totalFileSize, uploadedSoFar, initialFraction) // Clear box before starting this chunk let actorRequest = ActorRequest() From 23d611b4ea49bde74e498c0a2f2c543c3ba4dd7a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 10:26:14 +0200 Subject: [PATCH 16/21] renameAlbum Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index c41545d5..814d8e60 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -141,13 +141,12 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + if let error = response.error { let error = NKError(error: error, afResponse: response, responseData: response.data) options.queue.async { completion(.failure(error)) } - case .success: - options.queue.async { completion(.success((account))) } + } else { + options.queue.async { completion(.success(account)) } } } } From b3f744c9cd9052022a9e78196cdac700f31a75a4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 11:13:48 +0200 Subject: [PATCH 17/21] fix Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Upload.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Upload.swift b/Sources/NextcloudKit/NextcloudKit+Upload.swift index a84d13c1..0f112319 100644 --- a/Sources/NextcloudKit/NextcloudKit+Upload.swift +++ b/Sources/NextcloudKit/NextcloudKit+Upload.swift @@ -79,7 +79,8 @@ public extension NextcloudKit { options.queue.async { taskHandler(task) } }) .uploadProgress { progress in options.queue.async { progressHandler(progress) } - } .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + } .response(queue: self.nkCommonInstance.backgroundQueue) { response in + let response = response.map { $0 ?? Data() } options.queue.async { completionHandler(account, response, self.evaluateResponse(response)) } From 7f68f18060c2ee6d9d25357737336ed72b23327f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 11:20:10 +0200 Subject: [PATCH 18/21] fix Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+WebDAV.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift index 61d7c76f..38b38497 100644 --- a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift +++ b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift @@ -206,7 +206,8 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + let response = response.map { $0 ?? Data() } let result = self.evaluateResponse(response) options.queue.async { From 18a5f5702063a4305f665574a8a38ac5537c2c74 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 11:25:16 +0200 Subject: [PATCH 19/21] deleteFileOrFolder Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+WebDAV.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift index 38b38497..8ebd69a4 100644 --- a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift +++ b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift @@ -119,7 +119,8 @@ public extension NextcloudKit { nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + }.response(queue: self.nkCommonInstance.backgroundQueue) { response in + let response = response.map { $0 ?? Data() } let result = self.evaluateResponse(response) options.queue.async { From e1bb08ca3a04211cee0c4b3b8ceae06e12284003 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 18 Sep 2026 11:34:59 +0200 Subject: [PATCH 20/21] deletePhotoFromAlbumAsync Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NextcloudKit+Albums.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 814d8e60..7c3202d1 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -334,7 +334,7 @@ public extension NextcloudKit { account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async throws -> String { + ) async throws { try await withCheckedThrowingContinuation { continuation in deletePhotoFromAlbum( albumName: albumName, @@ -343,7 +343,13 @@ public extension NextcloudKit { options: options, taskHandler: taskHandler ) { result in - continuation.resume(with: result) + switch result { + case .success: + continuation.resume() + + case .failure(let error): + continuation.resume(throwing: error) + } } } } From d29285e2e20bd3dd63640c8d9f7fbc3ae87a2b4f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 19 Sep 2026 11:22:19 +0200 Subject: [PATCH 21/21] fix Signed-off-by: Marino Faggiana --- .../NextcloudKit/NextcloudKit+Albums.swift | 38 +++++++++---- .../AlbumPhotoDeletionTests.swift | 45 +++++++++++++++ .../AlbumResponseTests.swift | 55 +++++++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift create mode 100644 Tests/NextcloudKitUnitTests/AlbumResponseTests.swift diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift index 7c3202d1..81c9c333 100644 --- a/Sources/NextcloudKit/NextcloudKit+Albums.swift +++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift @@ -23,6 +23,7 @@ public extension NextcloudKit { serverUrl: nkSession.urlBase, endpoint: endpoint ), + let collectionURL = try? url.asURL(), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { return options.queue.async { completion(.failure(NKError.urlError)) } } @@ -60,7 +61,7 @@ public extension NextcloudKit { guard let data = response.data else { return options.queue.async { completion(.failure(NKError.invalidData)) } } - let albums = self.parseAlbumsXML(account: account, data: data) + let albums = self.parseAlbumsXML(account: account, data: data, collectionURL: collectionURL) options.queue.async { completion(.success(albums)) } } } @@ -254,14 +255,7 @@ public extension NextcloudKit { let destination = try? destinationUrl.asURL() else { return options.queue.async { completion(.failure(NKError.urlError)) } } - let sourceUrlString: String = { - if sourcePath.lowercased().hasPrefix("http") { - return sourcePath - } else { - return nkSession.urlBase + sourcePath - } - }() - guard let sourceUrl = sourceUrlString.encodedToUrl else { + guard let sourceUrl = albumPhotoSourceURL(sourcePath: sourcePath, serverUrl: nkSession.urlBase) else { return options.queue.async { completion(.failure(NKError.urlError)) } } @@ -297,6 +291,11 @@ public extension NextcloudKit { options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result) -> Void) { + // An empty file name would target the album itself with DELETE. + guard !fileName.isEmpty else { + return options.queue.async { completion(.failure(NKError.invalidData)) } + } + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName), let url = nkCommonInstance.createStandardUrl( @@ -371,13 +370,32 @@ public extension NextcloudKit { return encodedComponents.joined(separator: "/") + (fileName == nil ? "/" : "") } - private func parseAlbumsXML(account: String, data: Data) -> [NKPhotoAlbum] { + // Resolve root-relative DAV paths against the origin, and relative paths against the installation. + internal func albumPhotoSourceURL(sourcePath: String, serverUrl: String) -> URL? { + guard let encodedSource = sourcePath.urlEncoded, + let encodedBase = serverUrl.urlEncoded, + let baseURL = URL(string: encodedBase.hasSuffix("/") ? encodedBase : encodedBase + "/"), + let sourceURL = URL(string: encodedSource, relativeTo: baseURL)?.absoluteURL, + let scheme = sourceURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + return sourceURL + } + + internal func parseAlbumsXML(account: String, data: Data, collectionURL: URL) -> [NKPhotoAlbum] { let xml = XML.parse(data) var albums: [NKPhotoAlbum] = [] let elements = xml["d:multistatus", "d:response"] for element in elements { let href = element["d:href"].element?.text ?? "" + // PROPFIND also returns the requested collection, which is not an album. + guard !href.isEmpty, + let resourceURL = URL(string: href, relativeTo: collectionURL), + resourceURL.pathComponents != collectionURL.pathComponents else { + continue + } let prop = element["d:propstat"]["d:prop"] let lastPhoto = prop["nc:last-photo"].element?.text let nbItems = prop["nc:nbItems"].element?.text.flatMap { Int($0) } diff --git a/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift b/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift new file mode 100644 index 00000000..183a8be3 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +import NextcloudKit + +struct AlbumPhotoDeletionTests { + @Test func emptyFileNameIsRejectedBeforeSessionLookup() async { + // No session: invalid input must be rejected before URL or request creation. + let result: Result = await withCheckedContinuation { continuation in + NextcloudKit.shared.deletePhotoFromAlbum( + albumName: "Holiday", + fileName: "", + account: UUID().uuidString, + taskHandler: { _ in Issue.record("Deletion must not create a network task") } + ) { result in + continuation.resume(returning: result) + } + } + switch result { + case .success: + Issue.record("An empty file name must not succeed") + case .failure(let error): + #expect(error == .invalidData) + } + } + + @Test func asyncDeletionRejectsEmptyFileName() async { + do { + try await NextcloudKit.shared.deletePhotoFromAlbumAsync( + albumName: "Holiday", + fileName: "", + account: UUID().uuidString, + taskHandler: { _ in Issue.record("Deletion must not create a network task") } + ) + Issue.record("An empty file name must throw") + } catch let error as NKError { + #expect(error == .invalidData) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift b/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift new file mode 100644 index 00000000..d4411895 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import NextcloudKit + +struct AlbumResponseTests { + @Test(arguments: [ + "/nextcloud/remote.php/dav/photos/alice/albums/", + "/nextcloud/remote.php/dav/photos/alice/albums", + "https://example.com/nextcloud/remote.php/dav/photos/alice/albums/" + ]) + func excludesCollectionButKeepsAnAlbumNamedAlbums(collectionHref: String) throws { + let collectionURL = try #require(URL(string: "https://example.com/nextcloud/remote.php/dav/photos/alice/albums/")) + let albumHref = "/nextcloud/remote.php/dav/photos/alice/albums/albums/" + let xml = """ + + + \(collectionHref) + 1 + HTTP/1.1 200 OK + + + \(albumHref) + 2 + HTTP/1.1 200 OK + + + """ + let albums = NextcloudKit.shared.parseAlbumsXML(account: "alice", data: Data(xml.utf8), collectionURL: collectionURL) + #expect(albums.map(\.href) == [albumHref]) + #expect(albums.first?.name == "albums") + } + + @Test(arguments: [ + "/nextcloud/remote.php/dav/files/alice/photo.jpg", + "remote.php/dav/files/alice/photo.jpg", + "https://example.com/nextcloud/remote.php/dav/files/alice/photo.jpg" + ]) + func resolvesSourcePathsWithoutDuplicatingInstallation(sourcePath: String) { + for base in ["https://example.com/nextcloud", "https://example.com/nextcloud/"] { + let url = NextcloudKit.shared.albumPhotoSourceURL(sourcePath: sourcePath, serverUrl: base) + #expect(url?.absoluteString == "https://example.com/nextcloud/remote.php/dav/files/alice/photo.jpg") + } + } + + @Test func preservesSpecialCharactersInRawSourceNames() { + let url = NextcloudKit.shared.albumPhotoSourceURL( + sourcePath: "/nextcloud/remote.php/dav/files/alice/holiday #100%.jpg", + serverUrl: "https://example.com/nextcloud") + #expect(url?.absoluteString == "https://example.com/nextcloud/remote.php/dav/files/alice/holiday%20%23100%25.jpg") + } +}