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
55 changes: 55 additions & 0 deletions Sources/NextcloudKit/Models/NKPhotoAlbum.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2026 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation

public struct NKPhotoAlbum: Identifiable, Hashable, Sendable {
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?

/// 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
}
}
}
424 changes: 424 additions & 0 deletions Sources/NextcloudKit/NextcloudKit+Albums.swift

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions Sources/NextcloudKit/NextcloudKit+Upload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -311,9 +312,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()
Expand Down
6 changes: 4 additions & 2 deletions Sources/NextcloudKit/NextcloudKit+WebDAV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -206,7 +207,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 {
Expand Down
45 changes: 45 additions & 0 deletions Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift
Original file line number Diff line number Diff line change
@@ -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<String, NKError> = 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)")
}
}
}
55 changes: 55 additions & 0 deletions Tests/NextcloudKitUnitTests/AlbumResponseTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>\(collectionHref)</d:href>
<d:propstat><d:prop><nc:nbItems>1</nc:nbItems></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
<d:response>
<d:href>\(albumHref)</d:href>
<d:propstat><d:prop><nc:nbItems>2</nc:nbItems></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
</d:multistatus>
"""
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")
}
}
40 changes: 40 additions & 0 deletions Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading