diff --git a/CommonsAPI/Sources/CommonsAPI/API.swift b/CommonsAPI/Sources/CommonsAPI/API.swift index b143e53..b46ca1b 100644 --- a/CommonsAPI/Sources/CommonsAPI/API.swift +++ b/CommonsAPI/Sources/CommonsAPI/API.swift @@ -1171,12 +1171,18 @@ LIMIT \(limit) return entitiesDict } - /// Check if a media file already exists by filename - public func checkIfFileExists(filename: String) async throws -> FilenameExistsResult { + public func checkIfFileExists(filename: String) async throws -> FilenameExistsResult? { + let result = try await checkIfFilesExists(filenames: [filename]) + return result[filename] + } + + /// Check if a media file already exists by filename, returns [filename: FilenameExistsResult] + public func checkIfFilesExists(filenames: [String]) async throws -> [String: FilenameExistsResult] { + let titles = filenames.map { "File:" + $0 } let query: Parameters = [ "action": "query", "format": "json", - "titles": "File:" + filename, + "titles": titles.joined(separator: "|"), "formatversion": "2", "curtimestamp": "1" ] @@ -1184,17 +1190,34 @@ LIMIT \(limit) let request = try GET(url: commonsEndpoint, query: query) let (data, response) = try await urlSession.data(for: request) let parsedResponse = try parse(QueryResponse.self, from: data, response: response) - guard let fileInfo = parsedResponse.query?.pages?.first else { + + guard let pages = parsedResponse.query?.pages else { throw CommonAPIError.missingResponseValues } - if fileInfo.invalid == true { - return .invalidFilename - } else if fileInfo.missing == true { - return .doesNotExist - } else { - return .exists + var result: [String: FilenameExistsResult] = [:] + + for page in pages { + guard let title = page.title else { + continue + } + + var fromTitle = parsedResponse.query?.normalized?.first { normalizedResult in + normalizedResult.to == title + }?.from ?? title + + fromTitle = String(fromTitle.split(separator: "File:")[0]) + + if page.invalid == true { + result[fromTitle] = .invalidFilename + } else if page.missing == true { + result[fromTitle] = .doesNotExist + } else { + result[fromTitle] = .exists + } } + + return result } // action=titleblacklist @@ -1312,7 +1335,6 @@ LIMIT \(limit) } } } - /// https://commons.wikimedia.org/w/api.php?action=upload&format=json&filename=&comment=&file=...&stash=1&token=&formatversion=2 public func publish(file: MediaFileUploadable, csrfToken: String, startStep: PublishingStep = .uploadData) -> AsyncStream { @@ -1321,6 +1343,7 @@ LIMIT \(limit) AsyncStream { continuation in Task { + do { if startStep.unstashRequired { var parameters: Parameters = [ @@ -1394,7 +1417,7 @@ LIMIT \(limit) title: "File:\(file.filename)", labels: file.captions, statements: file.claims, - comment: "Created initial structured data after upload" + summary: "initial structured data" ) continuation.yield(.published) @@ -1598,7 +1621,7 @@ LIMIT \(limit) title: String, labels: [LanguageString], statements: [WikidataClaim], - comment: String? + summary: String? ) async throws { let token = try await fetchCSRFToken() @@ -1625,24 +1648,24 @@ LIMIT \(limit) throw CommonAPIError.failedToEncodeJSONData } - let commentString: String + let summaryString: String - if let comment { - commentString = comment + if let summary { + summaryString = summary } else if !labels.isEmpty, !statements.isEmpty { - commentString = "Edited labels (\(labels.map(\.languageCode).joined(separator: ", "))) and structured data statements" + summaryString = "Edited labels (\(labels.map(\.languageCode).joined(separator: ", "))) and structured data statements" } else if labels.isEmpty { - commentString = "Edited structured data statements" + summaryString = "Edited structured data statements" } else if statements.isEmpty { - commentString = "Edited labels (\( labels.map(\.languageCode).joined(separator: ", ")))" + summaryString = "Edited labels (\( labels.map(\.languageCode).joined(separator: ", ")))" } else { - commentString = "Edited labels or structured data statements" + summaryString = "Edited labels or structured data statements" assertionFailure() } let form: Parameters = [ "action": "wbeditentity", - "comment": commentString, + "summary": summaryString, "token": token, "format": "json", "title": title, diff --git a/CommonsAPI/Sources/CommonsAPI/Types/Decodable Types/Decodable Types.swift b/CommonsAPI/Sources/CommonsAPI/Types/Decodable Types/Decodable Types.swift index 7476d66..c79e893 100644 --- a/CommonsAPI/Sources/CommonsAPI/Types/Decodable Types/Decodable Types.swift +++ b/CommonsAPI/Sources/CommonsAPI/Types/Decodable Types/Decodable Types.swift @@ -897,6 +897,13 @@ public struct GeosearchListResponse: Decodable, Sendable { internal struct FileExistenceResponse: Decodable, Sendable { let pages: [Item]? + let normalized: [Normalized]? + + struct Normalized: Decodable, Sendable { + let fromencoded: Bool + let from: String + let to: String + } struct Item: Decodable, Sendable { let pageid: Int64? diff --git a/CommonsAPI/Tests/CommonsAPITests.swift b/CommonsAPI/Tests/CommonsAPITests.swift index 60aafd8..9a8f5e7 100644 --- a/CommonsAPI/Tests/CommonsAPITests.swift +++ b/CommonsAPI/Tests/CommonsAPITests.swift @@ -202,14 +202,13 @@ struct CommonsEndToEndTests { @Test("list sub-categories", arguments: ["Physics"]) func fetchCategoryInfo(category: String) async throws { - let info = try await api.fetchCategoryMembers(of: category) + let info = try await api.fetchCategoryMembers(of: category, sort: nil) #expect(info != nil) guard let info else { return } #expect(info.parentCategories.count > 0) #expect(info.subCategories.count > 5) - #expect(info.wikidataItem == .physicsCategory) } @Test("list images in category", arguments: ["Physics"]) diff --git a/CommonsFinder.xcodeproj/project.pbxproj b/CommonsFinder.xcodeproj/project.pbxproj index 7907271..532c787 100644 --- a/CommonsFinder.xcodeproj/project.pbxproj +++ b/CommonsFinder.xcodeproj/project.pbxproj @@ -127,17 +127,24 @@ Database/FTS5Tokenizer.swift, Database/Model/Category.swift, Database/Model/CategoryInfo.swift, + Database/Model/Drafts/Draftable.swift, + Database/Model/Drafts/MediaFileDraft.swift, + Database/Model/Drafts/MultiDraft.swift, + Database/Model/Drafts/MultiDraftInfo.swift, + Database/Model/Drafts/Types/DraftAuthor.swift, + Database/Model/Drafts/Types/DraftSource.swift, + Database/Model/Drafts/Types/LocationHandling.swift, "Database/Model/Extensions/MediaFile+createAttributedStringDescription.swift", "Database/Model/Extensions/MediaFile+InitFromAPI.swift", "Database/Model/Extensions/MediaFile+makeRandomUploaded.swift", "Database/Model/Extensions/MediaFileDraft+DebugDraft.swift", "Database/Model/Extensions/MediaFileInfo+ImageRequest.swift", "Database/Model/Extensions/MediaFileInfo+sortedByLastViewed.swift", + "Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift", "Database/Model/Extensions/WikidataItem+InitFromAPI.swift", "Database/Model/Extensions/WikidataItem+Thumbnail.swift", Database/Model/ItemInteraction.swift, Database/Model/MediaFile.swift, - Database/Model/MediaFileDraft.swift, Database/Model/MediaFileInfo.swift, DataFetching/DataAccess.swift, "Generic Extensions/Array+popFirstN.swift", @@ -181,12 +188,13 @@ Tests/OtherTests.swift, Tests/PublishHelpersTests.swift, Types/CaptionWithDescription.swift, + Types/DraftIDType.swift, Types/DraftMediaLicense.swift, "Types/DraftMediaLicense+CommonsAPI.swift", Types/FileNameType.swift, Types/FileNameTypeTuple.swift, Types/ImageAnalysisResult.swift, - "Types/MediaFileDraft+uploadDisabledReason.swift", + Types/NameValidationError.swift, Types/NewDraftOptions.swift, Types/OptionBarState.swift, Types/RelatedCategoriesType.swift, @@ -200,6 +208,7 @@ "Utilities/CLLocation+gpsDictionary.swift", Utilities/ExifData.swift, Utilities/FileAnalysisHelpers.swift, + Utilities/FilenameUtils.swift, Utilities/GeoPlacemarkCache.swift, Utilities/GeoVectorMath.swift, Utilities/Logging.swift, @@ -221,23 +230,34 @@ Views/CategoryView/RelatedCategoryView.swift, Views/ContextMenus/CategoryContextMenu.swift, Views/ContextMenus/MediaFileContextMenu.swift, + Views/DraftViews/BaseDraftImageView.swift, + Views/DraftViews/DraftSheetModifer.swift, + Views/DraftViews/FileLocationMapView.swift, + Views/DraftViews/FilenameErrorButton.swift, + Views/DraftViews/FilenameTip.swift, + Views/DraftViews/LicensePicker.swift, + Views/DraftViews/Model/FileImportModel.swift, + Views/DraftViews/Model/FileItem.swift, + Views/DraftViews/Model/MultiDraftModel.swift, + Views/DraftViews/Model/NameValidationResult.swift, + Views/DraftViews/Model/SingleDraftModel.swift, + Views/DraftViews/MultiDraftListItem.swift, + Views/DraftViews/MultiDraftOverviewList.swift, + Views/DraftViews/MultiDraftSheetModifier.swift, + Views/DraftViews/MultiDraftView.swift, + Views/DraftViews/SingleDraftSheetModifier.swift, + Views/DraftViews/SingleDraftView.swift, + Views/DraftViews/TagPicker/TagButton.swift, + Views/DraftViews/TagPicker/TagLabel.swift, + Views/DraftViews/TagPicker/TagModel.swift, + Views/DraftViews/TagPicker/TagPicker.swift, + Views/DraftViews/TagPicker/TagPickerModel.swift, + Views/DraftViews/TimezonePicker.swift, + "Views/Extensions/CLLocationCoordinate2D+description.swift", "Views/Extensions/CLLocationManager+isLocationAutorized.swift", "Views/Extensions/MediaFile+localizedDisplayCaption.swift", "Views/Extensions/MediaFileDraftModel+ImageRequest.swift", "Views/Extensions/UserDefaults+accessors.swift", - Views/FileCreateView/DraftSheetModifer.swift, - Views/FileCreateView/FilenameTip.swift, - Views/FileCreateView/LicensePicker.swift, - Views/FileCreateView/Model/FileImportModel.swift, - Views/FileCreateView/Model/FileItem.swift, - Views/FileCreateView/Model/MediaFileDraftModel.swift, - Views/FileCreateView/SingleImageDraftView.swift, - Views/FileCreateView/TagPicker/TagButton.swift, - Views/FileCreateView/TagPicker/TagLabel.swift, - Views/FileCreateView/TagPicker/TagModel.swift, - Views/FileCreateView/TagPicker/TagPicker.swift, - Views/FileCreateView/TagPicker/TagPickerModel.swift, - Views/FileCreateView/TimezonePicker.swift, Views/FileDetailView/FileDetailView.swift, Views/FileDetailView/FileGroupBoxStyle.swift, Views/FileDetailView/FileLoadView.swift, @@ -295,7 +315,6 @@ "Views/Reusable Views/DeepCategoryToggle.swift", "Views/Reusable Views/Deprecated/DraggablePseudoSheet.swift", "Views/Reusable Views/Deprecated/PseudoSheet.swift", - "Views/Reusable Views/DraftFileListItem.swift", "Views/Reusable Views/InlineMap.swift", "Views/Reusable Views/LanguageButtons.swift", "Views/Reusable Views/MediaDowloadSheet.swift", diff --git a/CommonsFinder/Assets.xcassets/PublishingInProgressAccentColor.colorset/Contents.json b/CommonsFinder/Assets.xcassets/PublishingInProgressAccentColor.colorset/Contents.json new file mode 100644 index 0000000..f2c6d72 --- /dev/null +++ b/CommonsFinder/Assets.xcassets/PublishingInProgressAccentColor.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.534", + "red" : "0.334" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonsFinder/ContentView.swift b/CommonsFinder/ContentView.swift index 61c5ea8..8820c31 100644 --- a/CommonsFinder/ContentView.swift +++ b/CommonsFinder/ContentView.swift @@ -46,31 +46,17 @@ struct ContentView: View { } } - // Tab("Events", systemImage: "figure.socialdance", value: Navigation.TabItem.events) { - // NavigationStack(path: $navigation.eventsPath) { - // Text("Current and nearby events") - // .modifier(CommonNavigationDestination()) - // } - // } - Tab(value: Navigation.TabItem.search, role: .search) { NavigationStack(path: $navigation.searchPath) { SearchView() .modifier(CommonNavigationDestination()) } - } } .sheet(item: $navigation.isAuthSheetOpen, content: AuthView.init) - // .sheet(item: $navigation.isEditingDraft) { destination in - // switch destination { - // case .existing(let files): - // FileCreateView(appDatabase: appDatabase, files: files) - // case .newDraft(let options): - // FileCreateView(appDatabase: appDatabase, newDraftOptions: options) - // } - // } - .modifier(DraftSheetModifer(importModel: $navigation.isEditingDraft)) + .modifier(ImportFilesModifer(importModel: $navigation.isImportingFiles)) + .modifier(SingleDraftSheetModifier(draftedFileModel: $navigation.isEditingDraft)) + .modifier(MultiDraftSheetModifier(multiDraftModel: $navigation.isEditingMultipleDrafts)) .onOpenURL(perform: handleURL) .onContinueUserActivity(NSUserActivityTypeLiveActivity) { userActivity in guard let url = userActivity.webpageURL else { return } @@ -130,7 +116,7 @@ struct ContentView: View { let drafts: [MediaFileDraft] = urls.compactMap { temporaryPath in do { let fileItem = try FileItem(movingLocalFileFromPath: temporaryPath) - let draft = try MediaFileDraft(fileItem) + let draft = try MediaFileDraft(fileItem, newDraftOptions: nil) return try appDatabase.upsertAndFetch(draft) } catch { logger.error("Failed to move draft file from ShareExtension. \(error)") @@ -142,11 +128,13 @@ struct ContentView: View { Task { // A short visually delay to allow the opening app animations to settle a moment try? await Task.sleep(for: .milliseconds(200)) + + navigation.selectedTab = .home + if drafts.count > 1 { - // TODO: needs batch image implementation - navigation.selectedTab = .home - } else { - navigation.editDrafts(drafts: drafts) + navigation.editMultipleDrafts(multiDraftInfo: .init(multiDraft: .init(newDraftOptions: nil), drafts: drafts)) + } else if let draft = drafts.first { + navigation.editDraft(draft: draft) } } diff --git a/CommonsFinder/Database/AppDatabase+Queries.swift b/CommonsFinder/Database/AppDatabase+Queries.swift index ceb6ad2..9a25641 100644 --- a/CommonsFinder/Database/AppDatabase+Queries.swift +++ b/CommonsFinder/Database/AppDatabase+Queries.swift @@ -12,17 +12,34 @@ import os.log // MARK: - Queries +/// A @Query request that observes all drafts in the database +struct AllMultiDraftsRequest: ValueObservationQueryable { + static var defaultValue: [MultiDraftInfo] { [] } + + func fetch(_ db: Database) throws -> [MultiDraftInfo] { + do { + return + try MultiDraftInfo + .all() + .order(MultiDraft.Columns.addedDate.desc) + .fetchAll(db) + } catch { + logger.error("Failed to fetch all multiDrafts from db \(error)!") + return [] + } + } +} /// A @Query request that observes all drafts in the database -struct AllDraftsRequest: ValueObservationQueryable { +struct AllSingleDraftsRequest: ValueObservationQueryable { static var defaultValue: [MediaFileDraft] { [] } func fetch(_ db: Database) throws -> [MediaFileDraft] { do { return try MediaFileDraft + .filter { $0.multiDraftId == nil } .order(MediaFileDraft.Columns.addedDate.desc) - // .order(\.addedDate.desc) .fetchAll(db) } catch { logger.error("Failed to fetch all draft files from db \(error)!") diff --git a/CommonsFinder/Database/AppDatabase.swift b/CommonsFinder/Database/AppDatabase.swift index a78968c..39c48a0 100644 --- a/CommonsFinder/Database/AppDatabase.swift +++ b/CommonsFinder/Database/AppDatabase.swift @@ -250,7 +250,34 @@ nonisolated final class AppDatabase: Sendable { t.add(column: "publishingState", .jsonText) t.add(column: "publishingStateVerificationRequired", .boolean) t.add(column: "publishingError", .jsonText) + + } + } + + migrator.registerMigration("add size to MediaFileDraft, add MultiDraft, add relation to MediaFileDraft") { db in + try db.create(table: "multiDraft") { t in + t.autoIncrementedPrimaryKey("id") + t.column("addedDate", .date) + t.column("name", .text) + t.column("nameSuffix", .jsonText) + t.column("nameAdditionalFallbackSuffix", .jsonText) + t.column("captionWithDesc", .jsonText) + t.column("tags", .jsonText) + t.column("license", .text) + t.column("author", .jsonText) + t.column("source", .jsonText) + t.column("locationHandling", .jsonText) + t.column("selectedFilenameType") + t.column("publishingState") + t.column("uploadPossibleStatus") + } + + try db.alter(table: "mediaFileDraft") { t in + t.add(column: "size", .integer) + t.add(column: "multiDraftId", .integer) + .references("multiDraft", onDelete: .cascade) } + } return migrator @@ -697,6 +724,60 @@ extension AppDatabase { } } +// MARK: - MultiDraftInfo Writes +extension AppDatabase { + func upsertAndFetch(_ multiDraftInfo: MultiDraftInfo) throws -> MultiDraftInfo { + try dbWriter.write { db in + var multiDraftInfo = multiDraftInfo + let multiDraft = try multiDraftInfo.multiDraft.upsertAndFetch(db) + + for var draft in multiDraftInfo.drafts { + draft.multiDraftId = multiDraft.id + try draft.upsert(db) + } + + + let updated = + try MultiDraftInfo + .all() + .filter(id: multiDraft.id) + .fetchOne(db) + + guard let updated else { + throw DatabaseError.failedToFetchAfterUpdate + } + return updated + } + } + func delete(_ multiDraftInfo: MultiDraftInfo) throws { + let id = multiDraftInfo.multiDraft.id + try dbWriter.write { db in + // NOTE: sub-drafts *should* be deleted via cascade rule, so no need to delete them here separately. + _ = try multiDraftInfo.multiDraft.delete(db) + } + + #if DEBUG + let subDraftCountAfterDelete = try dbWriter.write { db in + try MediaFileDraft.filter { $0.multiDraftId == id }.fetchCount(db) + } + + assert( + subDraftCountAfterDelete == 0, + "We expect sub-drafts of a MultiDraft to be deleted via the cascade rule together with its parent." + ) + #endif + } + // MARK: - MultiDraft Writes + func updateMultiDraft(id: MultiDraft.ID, withPublishingStep publishingState: MultiDraft.PublishingState?) throws { + try dbWriter.write { db in + guard var draft = try MultiDraft.fetchOne(db, id: id) else { + throw DatabaseError.itemNotFound + } + draft.publishingState = publishingState + try draft.upsert(db) + } + } +} // MARK: - MediaFileDraft Writes extension AppDatabase { @@ -706,20 +787,19 @@ extension AppDatabase { } } - @discardableResult - func updateDraft(id: MediaFileDraft.ID, withPublishingStep publishingState: PublishingState?, verificationRequired: Bool) throws -> MediaFileDraft { + func updateDraft(id: MediaFileDraft.ID, withPublishingStep publishingState: MediaFileDraft.PublishingState?, verificationRequired: Bool) throws { try dbWriter.write { db in guard var draft = try MediaFileDraft.fetchOne(db, id: id) else { throw DatabaseError.itemNotFound } draft.publishingState = publishingState draft.publishingStateVerificationRequired = verificationRequired - return try draft.upsertAndFetch(db) + try draft.upsert(db) } } @discardableResult - func updateDraft(id: MediaFileDraft.ID, withPublishingError publishingError: PublishingError?) throws -> MediaFileDraft { + func updateDraft(id: MediaFileDraft.ID, withPublishingError publishingError: MediaFileDraft.PublishingError?) throws -> MediaFileDraft { try dbWriter.write { db in guard var draft = try MediaFileDraft.fetchOne(db, id: id) else { throw DatabaseError.itemNotFound @@ -729,10 +809,10 @@ extension AppDatabase { } } - func upsert(_ draft: MediaFileDraft) throws { + func upsert(_ draft: MediaFileDraft) throws -> MediaFileDraft { try dbWriter.write { db in var draft = draft - try draft.upsert(db) + return try draft.upsertAndFetch(db) } } @@ -879,6 +959,23 @@ nonisolated extension AppDatabase { } } + func fetchMultiDraftInfo(id: MultiDraftInfo.ID) throws -> MultiDraftInfo? { + try dbWriter.read { db in + try MultiDraftInfo + .all() + .filter(id: id) + .fetchOne(db) + } + } + + func fetchAllMultiDraftInfos() throws -> [MultiDraftInfo] { + try dbWriter.read { db in + try MultiDraftInfo + .all() + .fetchAll(db) + } + } + func fetchInterruptedDraftsRequiringVerification() throws -> [MediaFileDraft] { try dbWriter.read { db in try MediaFileDraft @@ -945,14 +1042,6 @@ nonisolated extension AppDatabase { .fetchAll(db) } } - - func fetchBookmarkedCategoryInfos() throws -> [CategoryInfo] { - try dbWriter.read { db in - try Category.including(required: Category.itemInteraction) - .asRequest(of: CategoryInfo.self) - .fetchAll(db) - } - } } nonisolated extension MediaFileInfo { static func fetchAll(ids: [String], db: Database) throws -> [Self] { @@ -964,6 +1053,22 @@ nonisolated extension MediaFileInfo { } } +nonisolated extension MultiDraftInfo { + static func all() -> QueryInterfaceRequest { + MultiDraft + .annotated( + with: (MultiDraft.drafts.sum(MediaFileDraft.Columns.size) ?? 0) + .forKey("combinedFileSizeInByte") + ) + .including(all: MultiDraft.drafts) + .asRequest(of: MultiDraftInfo.self) + } + + static func filter(id: MultiDraft.ID) -> QueryInterfaceRequest { + all().filter(key: id) + } +} + nonisolated extension CategoryInfo { /// takes redirections into account static func fetchAll(_ db: Database, wikidataIDs: [Category.WikidataID], resolveRedirections: Bool) throws -> [Self] { diff --git a/CommonsFinder/Database/Model/Drafts/Draftable.swift b/CommonsFinder/Database/Model/Drafts/Draftable.swift new file mode 100644 index 0000000..7f60d2d --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/Draftable.swift @@ -0,0 +1,17 @@ +// +// Draftable.swift +// CommonsFinder +// +// Created by Tom Brewe on 13.03.26. +// + +import Foundation + +nonisolated protocol Draftable { + var addedDate: Date { get } + var captionWithDesc: [CaptionWithDescription] { get } + var tags: [TagItem] { get } + var license: DraftMediaLicense? { get } + var author: DraftAuthor? { get } + var source: DraftSource? { get } +} diff --git a/CommonsFinder/Database/Model/MediaFileDraft.swift b/CommonsFinder/Database/Model/Drafts/MediaFileDraft.swift similarity index 71% rename from CommonsFinder/Database/Model/MediaFileDraft.swift rename to CommonsFinder/Database/Model/Drafts/MediaFileDraft.swift index 976ce7a..ed7759d 100644 --- a/CommonsFinder/Database/Model/MediaFileDraft.swift +++ b/CommonsFinder/Database/Model/Drafts/MediaFileDraft.swift @@ -5,6 +5,7 @@ // Created by Tom Brewe on 21.01.25. // + import CommonsAPI import CoreGraphics import CoreLocation @@ -21,9 +22,10 @@ import os.log // avoiding duplicates with wikidata structured data (eg. for location, date etc.) nonisolated - struct MediaFileDraft: Identifiable, Equatable, Hashable + struct MediaFileDraft: Draftable, Identifiable, Equatable, Hashable { // UUID-string + // FIXME: make this an auto-incrementable id let id: String var addedDate: Date @@ -61,15 +63,6 @@ nonisolated set { locationHandling = newValue ? .exifLocation : .noLocation } } - enum LocationHandling: Codable, Equatable, Hashable { - /// location data will be removed from EXIF if it exists inside the binary and won't be added to wikitext or structured data - case noLocation - /// location data from EXIF will be used for wikitext and structured data - case exifLocation - /// user defined location data will be used for wikitext and structured data, EXIF-location will be overwritten by user defined location - case userDefinedLocation(latitude: CLLocationDegrees, longitude: CLLocationDegrees, precision: CLLocationDegrees) - } - var tags: [TagItem] var license: DraftMediaLicense? @@ -78,21 +71,78 @@ nonisolated var width: Int? var height: Int? + /// in byte + var size: Int64? + + var multiDraftId: Int64? + + enum PublishingError: Equatable, Sendable, CustomStringConvertible, Codable, Hashable { + case twoFactorCodeRequired + case emailCodeRequired + case uploadWarnings([FileUploadResponse.Warning]) + case urlError(urlErrorCode: Int, errorDescription: String) + case error(errorDescription: String?, recoverySuggestion: String?) + case appQuitOrCrash + + var description: String { + switch self { + case .twoFactorCodeRequired: + "twoFactorCodeRequired" + case .emailCodeRequired: + "emailCodeRequired" + case .uploadWarnings(let array): + "uploadWarnings \(array.description)" + case .error(let errorDescription, let recoverySuggestion): + "error \(errorDescription ?? ""), \(recoverySuggestion ?? "")" + case .urlError(let urlErrorCode, let errorDescription): + "urlError \(urlErrorCode) \(errorDescription)" + case .appQuitOrCrash: + "appQuitOrCrash" + } + } - enum DraftAuthor: Codable, Equatable, Hashable { - case appUser - case custom(name: String, wikimediaUsername: String?, url: URL?) - case wikidataId(wikidataItem: WikidataItemID) + static func == (lhs: PublishingError, rhs: PublishingError) -> Bool { + lhs.description == rhs.description + } } - enum DraftSource: Codable, Equatable, Hashable { - // see: https://commons.wikimedia.org/wiki/Commons:Structured_data/Modeling/Source - // "Wikidata: *\(id)*"P7482 + enum PublishingState: Equatable, Sendable, Identifiable, CustomStringConvertible, Codable, Hashable { + case uploading(_ fractionCompleted: Double) + case uploaded(filekey: String) + case unstashingFile(filekey: String) + case creatingWikidataClaims + case published + + var uploadProgress: Double? { + if case .uploading(let fractionCompleted) = self { + fractionCompleted + } else { + nil + } + } + + var id: String { + description + } - case own - case fileFromTheWeb(URL) - // TODO: check correct modelling - case book(WikidataItemID, page: Int) + var description: String { + switch self { + case .uploading(let fractionCompleted): + "uploading \(fractionCompleted)" + case .uploaded(let filekey): + "filekey \(filekey)" + case .creatingWikidataClaims: + "creatingWikidataClaims" + case .unstashingFile: + "unstashingFile" + case .published: + "published" + } + } + + static func == (lhs: PublishingState, rhs: PublishingState) -> Bool { + lhs.description == rhs.description + } } } @@ -143,6 +193,8 @@ nonisolated case source case width case height + case size + case multiDraftId } // Define database columns from CodingKeys @@ -162,10 +214,12 @@ nonisolated static let width = Column(CodingKeys.width) static let height = Column(CodingKeys.height) + static let size = Column(CodingKeys.size) static let license = Column(CodingKeys.license) static let author = Column(CodingKeys.author) static let source = Column(CodingKeys.source) + static let multiDraftId = Column(CodingKeys.multiDraftId) } @@ -182,15 +236,17 @@ nonisolated self.captionWithDesc = try container.decode([CaptionWithDescription].self, forKey: .captionWithDesc) self.inceptionDate = try container.decode(Date.self, forKey: .inceptionDate) self.timezone = try container.decodeIfPresent(String.self, forKey: .timezone) - self.locationHandling = try container.decodeIfPresent(MediaFileDraft.LocationHandling.self, forKey: .locationHandling) + self.locationHandling = try container.decodeIfPresent(LocationHandling.self, forKey: .locationHandling) self.license = try container.decodeIfPresent(DraftMediaLicense.self, forKey: .license) - self.author = try container.decodeIfPresent(MediaFileDraft.DraftAuthor.self, forKey: .author) - self.source = try container.decodeIfPresent(MediaFileDraft.DraftSource.self, forKey: .source) + self.author = try container.decodeIfPresent(DraftAuthor.self, forKey: .author) + self.source = try container.decodeIfPresent(DraftSource.self, forKey: .source) self.width = try container.decodeIfPresent(Int.self, forKey: .width) self.height = try container.decodeIfPresent(Int.self, forKey: .height) + self.size = try container.decodeIfPresent(Int64.self, forKey: .size) self.publishingState = try container.decodeIfPresent(PublishingState.self, forKey: .publishingState) self.publishingError = try container.decodeIfPresent(PublishingError.self, forKey: .publishingError) self.publishingStateVerificationRequired = try container.decodeIfPresent(Bool.self, forKey: .publishingStateVerificationRequired) ?? false + self.multiDraftId = try container.decodeIfPresent(Int64.self, forKey: .multiDraftId) if let tags = try? container.decode([TagItem].self, forKey: .tags) { self.tags = tags } else { @@ -232,19 +288,24 @@ extension MediaFileDraft { extension MediaFileDraft { /// creates a new draft from an FileItem by reading its EXIF-Data filling the fields as complete as possible at this stage - init(_ fileItem: FileItem) throws { + init(_ fileItem: FileItem, newDraftOptions: NewDraftOptions?) throws { id = UUID().uuidString addedDate = .now localFileName = fileItem.localFileName finalFilename = "" - name = localFileName + name = "" uploadPossibleStatus = nil selectedFilenameType = .captionAndDate + if let initialTag = newDraftOptions?.tag { + tags = [initialTag] + } else { + tags = [] + } + let languageCode = Locale.current.wikiLanguageCodeIdentifier captionWithDesc = [.init(languageCode: languageCode)] - tags = [] license = UserDefaults.standard.defaultPublishingLicense author = .appUser source = .own @@ -280,7 +341,13 @@ extension MediaFileDraft { width = exifData.normalizedWidth height = exifData.normalizedHeight + } + if let fileURL = localFileURL(), + let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path()), + let bytes = fileAttributes[.size] as? Int64 + { + size = bytes } } } diff --git a/CommonsFinder/Database/Model/Drafts/MultiDraft.swift b/CommonsFinder/Database/Model/Drafts/MultiDraft.swift new file mode 100644 index 0000000..6b889d8 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/MultiDraft.swift @@ -0,0 +1,136 @@ +// +// MultiDraft.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import Foundation +import GRDB + +/// A container type storing attributes that will be used for its sub/child-MediaFileDrafts +/// for relevant views and for uploading. +nonisolated struct MultiDraft: Draftable, Identifiable, Equatable, Hashable { + typealias MultiDraftID = Int64 + + let id: MultiDraftID? + let addedDate: Date + /// The (base-)name is used to construct individual file names by adding the nameSuffix + var name: String + var nameSuffix: MultiFileNameSuffix + /// if name+nameSuffix is already taken, this suffix will be appended additionally if possible + var nameAdditionalFallbackSuffix: MultiFileFallbackSuffix + + var captionWithDesc: [CaptionWithDescription] + var tags: [TagItem] + var license: DraftMediaLicense? + var author: DraftAuthor? + var source: DraftSource? + var locationHandling: LocationHandling? + + var locationEnabled: Bool { + get { locationHandling == .exifLocation } + set { locationHandling = newValue ? .exifLocation : .noLocation } + } + + var selectedFilenameType: FileNameType + var uploadPossibleStatus: UploadPossibleStatus? + + /// tracks the overall publishing state of a multi-upload that is in progress or has finished. + /// the more detailed per-file publishing state is stored in the individial MediaFileDraft items. + var publishingState: PublishingState? + + enum MultiFileNameSuffix: Equatable, Hashable, Codable { + /// eg. 001, 002 .... 999 + case numberingZeroPadded + /// eg. 1, 2 .... 999 + case numbering + } + + enum MultiFileFallbackSuffix: Equatable, Hashable, Codable { + /// from B to Z + case asciiLetters + } + + struct PublishingState: Equatable, Hashable, Codable { + /// This is an aggregated progress of all uploads, succesfull or failed, normalized to 0....1, + /// The same value is display via tzhe BGProcessingTask that shows in the Dynamic Island + var overallProgress: Double + + /// This will `true` after all files have been processed, no matter + /// whether errors did occor on some files or not. + var isFinished: Bool + + var completedCount: Int + + /// usually this would match the amount of linked sub-drafts + /// it may differ if the upload was re-started by the user after errors occured + /// and some files are already successfully uploaded, but not all. + var totalCount: Int + } +} + + +extension MultiDraft { + init(newDraftOptions: NewDraftOptions?) { + id = nil + addedDate = .now + name = "" + nameSuffix = .numbering + nameAdditionalFallbackSuffix = .asciiLetters + + let languageCode = Locale.current.wikiLanguageCodeIdentifier + captionWithDesc = [.init(languageCode: languageCode)] + + if let initialTag = newDraftOptions?.tag { + tags = [initialTag] + } else { + tags = [] + } + + license = UserDefaults.standard.defaultPublishingLicense + author = .appUser + source = .own + + locationHandling = .exifLocation + selectedFilenameType = .captionAndDate + uploadPossibleStatus = nil + } +} + + +// MARK: - Database + +/// Make MultiDraft a Codable Record. +/// +/// +/// +/// See +/// +nonisolated extension MultiDraft: Codable, FetchableRecord, MutablePersistableRecord { + static let drafts = hasMany(MediaFileDraft.self).forKey("drafts") + + enum CodingKeys: CodingKey { + case id + case addedDate + case name + case nameSuffix + case nameAdditionalFallbackSuffix + case captionWithDesc + case tags + case license + case author + case source + case locationHandling + case selectedFilenameType + case publishingState + case uploadPossibleStatus + + } + + // Define database columns from CodingKeys + enum Columns { + static let id = Column(CodingKeys.id) + static let addedDate = Column(CodingKeys.addedDate) + } +} diff --git a/CommonsFinder/Database/Model/Drafts/MultiDraftInfo.swift b/CommonsFinder/Database/Model/Drafts/MultiDraftInfo.swift new file mode 100644 index 0000000..fc93b0c --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/MultiDraftInfo.swift @@ -0,0 +1,50 @@ +// +// MultiDraftInfo.swift +// CommonsFinder +// +// Created by Tom Brewe on 13.03.26. +// + +import Foundation +import GRDB + +nonisolated struct MultiDraftInfo: FetchableRecord, Equatable, Hashable, Decodable, Identifiable { + var multiDraft: MultiDraft + var drafts: [MediaFileDraft] + + var combinedFileSizeInByte: Int64 + + var id: MultiDraft.ID { + multiDraft.id + } + + + var publishingErrorUploadCount: Int { + guard multiDraft.publishingState != nil else { + return 0 + } + + return drafts.filter { $0.publishingError != nil }.count + } + + var publishingSuccessUploadCount: Int { + guard multiDraft.publishingState != nil else { + return 0 + } + return drafts.filter { $0.publishingState == .published }.count + } + + init(multiDraft: MultiDraft, drafts: [MediaFileDraft], combinedFileSizeInByte: Int64? = nil) { + self.multiDraft = multiDraft + self.drafts = drafts + + if let combinedFileSizeInByte { + self.combinedFileSizeInByte = combinedFileSizeInByte + } else { + self.combinedFileSizeInByte = drafts.reduce(0) { combined, draft in + combined + (draft.size ?? 0) + } + } + + } +} diff --git a/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift b/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift new file mode 100644 index 0000000..fa08937 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift @@ -0,0 +1,15 @@ +// +// DraftAuthor.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import CommonsAPI +import Foundation + +nonisolated enum DraftAuthor: Codable, Equatable, Hashable { + case appUser + case custom(name: String, wikimediaUsername: String?, url: URL?) + case wikidataId(wikidataItem: WikidataItemID) +} diff --git a/CommonsFinder/Database/Model/Drafts/Types/DraftSource.swift b/CommonsFinder/Database/Model/Drafts/Types/DraftSource.swift new file mode 100644 index 0000000..c05c65e --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/Types/DraftSource.swift @@ -0,0 +1,19 @@ +// +// DraftSource.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import CommonsAPI +import Foundation + +nonisolated enum DraftSource: Codable, Equatable, Hashable { + // see: https://commons.wikimedia.org/wiki/Commons:Structured_data/Modeling/Source + // "Wikidata: *\(id)*"P7482 + + case own + case fileFromTheWeb(URL) + // TODO: check correct modelling + case book(WikidataItemID, page: Int) +} diff --git a/CommonsFinder/Database/Model/Drafts/Types/LocationHandling.swift b/CommonsFinder/Database/Model/Drafts/Types/LocationHandling.swift new file mode 100644 index 0000000..cc33100 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/Types/LocationHandling.swift @@ -0,0 +1,19 @@ +// +// LocationHandling.swift +// CommonsFinder +// +// Created by Tom Brewe on 16.03.26. +// + + +import CoreLocation +import Foundation + +nonisolated enum LocationHandling: Codable, Equatable, Hashable { + /// location data will be removed from EXIF if it exists inside the binary and won't be added to wikitext or structured data + case noLocation + /// location data from EXIF will be used for wikitext and structured data + case exifLocation + /// user defined location data will be used for wikitext and structured data, EXIF-location will be overwritten by user defined location + case userDefinedLocation(latitude: CLLocationDegrees, longitude: CLLocationDegrees, precision: CLLocationDegrees) +} diff --git a/CommonsFinder/Database/Model/Extensions/MediaFileInfo+ImageRequest.swift b/CommonsFinder/Database/Model/Extensions/MediaFileInfo+ImageRequest.swift index 26a166e..31ea210 100644 --- a/CommonsFinder/Database/Model/Extensions/MediaFileInfo+ImageRequest.swift +++ b/CommonsFinder/Database/Model/Extensions/MediaFileInfo+ImageRequest.swift @@ -119,4 +119,12 @@ extension MediaFileDraft { } return nil } + + var localFileRequestResizedGridThumb: ImageRequest? { + if let fileURL = localFileURL() { + let imageResize = ImageProcessors.Resize(size: .init(width: 128, height: 128)) + return .init(url: fileURL, processors: [imageResize]) + } + return nil + } } diff --git a/CommonsFinder/Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift b/CommonsFinder/Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift new file mode 100644 index 0000000..a2fff58 --- /dev/null +++ b/CommonsFinder/Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift @@ -0,0 +1,67 @@ +// +// MultiDraftInfo+DebugDraft.swift +// CommonsFinder +// +// Created by Tom Brewe on 13.03.26. +// + +import Foundation + +extension MultiDraftInfo { + /// DEBUG ONLY value, will always be `false` in Release. + var isDebugDraft: Bool { + #if DEBUG + false + #else + return false + #endif + } + + static func makeRandom(id: Int64, imageCount: Int, uploadPossibleStatus: UploadPossibleStatus? = nil, finishedWithErrors: Bool = false) -> Self { + let date: Date = Date(timeIntervalSince1970: .random(in: 1..<5000)) + + var randomMultiDraft = MultiDraft( + id: id, + addedDate: date, + name: "Lorem Ipsum dolor sitit", + nameSuffix: .numbering, + nameAdditionalFallbackSuffix: .asciiLetters, + captionWithDesc: [.init(caption: "Lorem Caption", languageCode: "en")], + tags: [.init(.earth)], + license: .CC0, + author: .appUser, + source: .own, + selectedFilenameType: .captionAndDate, + uploadPossibleStatus: uploadPossibleStatus + ) + + var randomDrafts: [MediaFileDraft] = [] + + var publishingState: MultiDraft.PublishingState? = + if finishedWithErrors { + MultiDraft.PublishingState(overallProgress: 1, isFinished: true, completedCount: imageCount, totalCount: imageCount) + } else { + nil + } + + for idx in 0.. Status? { - guard let input else { return nil } - return switch input { - case .draft(let mediaFileDraft): - status(for: mediaFileDraft) - case .mediaFile(let mediaFile): - status(for: mediaFile) + if let key = input?.id { + cache[key] + } else { + nil } - } - func status(for draft: MediaFileDraft) -> Status? { - cache[draft.id] } - func status(for mediaFile: MediaFile) -> Status? { - cache[mediaFile.id] + func startAnalyzingIfNeeded(_ draft: MediaFileDraft) { + startAnalyzingIfNeeded(.draft(draft)) } - - func startAnalyzingIfNeeded(_ input: Input) { - switch input { - case .draft(let mediaFileDraft): - startAnalyzingIfNeeded(mediaFileDraft) - case .mediaFile(let mediaFile): - startAnalyzingIfNeeded(mediaFile) - } + func startAnalyzingIfNeeded(_ mediaFile: MediaFile) { + startAnalyzingIfNeeded(.mediaFile(mediaFile)) } - - func startAnalyzingIfNeeded(_ draft: MediaFileDraft) { - guard cache[draft.id] == nil else { return } - analyze(forKey: draft.id) { [appDatabase] in - await FileAnalysisHelpers.analyze(draft: draft, appDatabase: appDatabase) - } + func startAnalyzingIfNeeded(_ coordinate: CLLocationCoordinate2D, horizontalError: CLLocationDistance, bearing: CLLocationDegrees) { + startAnalyzingIfNeeded(.fileLocation(coordinate, horizontalError: horizontalError, bearing: bearing)) } - func startAnalyzingIfNeeded(_ mediaFile: MediaFile) { - guard cache[mediaFile.id] == nil else { return } - analyze(forKey: mediaFile.id) { [appDatabase] in - await FileAnalysisHelpers.analyze(mediaFile: mediaFile, appDatabase: appDatabase) - } - } + func startAnalyzingIfNeeded(_ input: Input) { + let key = input.id + guard cache[key] == nil else { return } + - private func analyze(forKey key: String, operation: @escaping () async -> ImageAnalysisResult?) { if cache.keys.count > 200 { cache.removeAll() } tasks[key]?.cancel() - + cache[key] = .analyzing tasks[key] = Task { - cache[key] = .analyzing - let result = await operation() - guard !Task.isCancelled else { return } + let result = + switch input { + case .draft(let draft): + await FileAnalysisHelpers.analyze(draft: draft, appDatabase: appDatabase) + case .mediaFile(let mediaFile): + await FileAnalysisHelpers.analyze(mediaFile: mediaFile, appDatabase: appDatabase) + case .fileLocation(let coordinate, let horizontalError, let bearing): + await FileAnalysisHelpers.analyze(coordinate: coordinate, horizontalError: horizontalError, bearing: bearing, appDatabase: appDatabase) + } cache[key] = .finished(result) tasks[key] = nil } + } + } diff --git a/CommonsFinder/Observable Models/Navigation.swift b/CommonsFinder/Observable Models/Navigation.swift index ac2ea20..835ee90 100644 --- a/CommonsFinder/Observable Models/Navigation.swift +++ b/CommonsFinder/Observable Models/Navigation.swift @@ -73,7 +73,9 @@ import os.log } // var isViewingFileSheetOpen: MediaFile.ID? - var isEditingDraft: FileImportModel? + var isImportingFiles: FileImportModel? + var isEditingDraft: SingleDraftModel? + var isEditingMultipleDrafts: MultiDraftModel? var isAuthSheetOpen: AuthNavigationDestination? enum DraftSheetNavItem: Identifiable, Equatable { @@ -176,16 +178,20 @@ extension Navigation { path[tabItem] = [] } - func editDrafts(drafts: [MediaFileDraft]) { - isEditingDraft = .init(existingDrafts: drafts) + func editDraft(draft: MediaFileDraft) { + isEditingDraft = .init(existingDraft: draft) + } + + func editMultipleDrafts(multiDraftInfo: MultiDraftInfo) { + isEditingMultipleDrafts = .init(multiDraftInfo) } func openNewDraft(options: NewDraftOptions) { - isEditingDraft = .init(newDraftOptions: options) + isImportingFiles = .init(newDraftOptions: options) } func openNewDraft() { - isEditingDraft = .init(newDraftOptions: nil) + isImportingFiles = .init(newDraftOptions: nil) } func viewFile(mediaFile: MediaFileInfo, namespace: Namespace.ID) { diff --git a/CommonsFinder/Observable Models/UploadManager/MediaFileUploadable+initWithDraft.swift b/CommonsFinder/Observable Models/UploadManager/MediaFileUploadable+initWithDraft.swift index f5f4c20..2ae9c66 100644 --- a/CommonsFinder/Observable Models/UploadManager/MediaFileUploadable+initWithDraft.swift +++ b/CommonsFinder/Observable Models/UploadManager/MediaFileUploadable+initWithDraft.swift @@ -14,7 +14,8 @@ import UniformTypeIdentifiers import os.log extension MediaFileUploadable { - init(_ draft: MediaFileDraft, appWikimediaUsername: String) throws(UploadManagerError) { + /// when the multiDraft is present, empty fields in the draft will be filled from the multiDraft + init(_ draft: MediaFileDraft, multiDraft: MultiDraft? = nil, appWikimediaUsername: String) throws(UploadManagerError) { guard let localFileURL = draft.localFileURL() else { throw UploadManagerError.fileURLMissing(id: draft.id) } @@ -28,21 +29,25 @@ extension MediaFileUploadable { throw UploadManagerError.finalFilenameMissing } - guard let license = draft.license else { + guard let license = draft.license ?? multiDraft?.license else { assertionFailure("The license must have been chosen before uploading.") throw UploadManagerError.licenseMissing } - guard let source = draft.source else { + guard let source = draft.source ?? multiDraft?.source else { assertionFailure("The source must have been chosen before uploading.") throw UploadManagerError.sourceMissing } - guard let author = draft.author else { + guard let author = draft.author ?? multiDraft?.author else { assertionFailure("The author must have been set before uploading.") throw UploadManagerError.authorMissing } + let mimeType = draft.mimeType + + let locationHandling = draft.locationHandling ?? multiDraft?.locationHandling + // see: https://commons.wikimedia.org/wiki/Template:Information let wikitextDate: String = draft.inceptionDate.formatted(.iso8601.year().month().day()) let wikitextSource: String @@ -60,7 +65,14 @@ extension MediaFileUploadable { var depictStatements: [WikidataClaim] = [] var categories: [String] = ["Uploaded with CommonsFinder", "Mobile upload"] - for tag in draft.tags { + let tags: [TagItem] = + if draft.tags.isEmpty { + multiDraft?.tags ?? [] + } else { + draft.tags + } + + for tag in tags { lazy var wikidataItemID = tag.baseItem.wikidataItemID lazy var commonsCategory = tag.baseItem.commonsCategory @@ -130,7 +142,7 @@ extension MediaFileUploadable { let exifData = draft.loadExifData() let exifCoordinate = exifData?.coordinate - switch draft.locationHandling { + switch locationHandling { case .exifLocation: if let exifData, let exifCoordinate { let precision = @@ -226,9 +238,20 @@ extension MediaFileUploadable { } } - statements.append(.mimeType(draft.mimeType)) + statements.append(.mimeType(mimeType)) + + let captionWithDesc = + if draft.captionWithDesc.isEmpty || draft.captionWithDesc.allSatisfy({ $0.caption.isEmpty && $0.fullDescription.isEmpty }) { + multiDraft?.captionWithDesc ?? [] + } else { + draft.captionWithDesc + } - let nonEmptyDescriptions: [(languageCode: LanguageCode, string: String)] = draft.captionWithDesc.compactMap { + let captions: [LanguageString] = captionWithDesc.map { + .init($0.caption, languageCode: $0.languageCode) + } + + let nonEmptyDescriptions: [(languageCode: LanguageCode, string: String)] = captionWithDesc.compactMap { $0.fullDescription.isEmpty ? nil : ($0.languageCode, $0.fullDescription) } var wikitextDescriptions: String = "" @@ -274,16 +297,11 @@ extension MediaFileUploadable { \(testUploadString) """ - - let captions: [LanguageString] = draft.captionWithDesc.map { - .init($0.caption, languageCode: $0.languageCode) - } - self.init( id: draft.id, fileURL: localFileURL, filename: finalFileName, - mimetype: draft.mimeType, + mimetype: mimeType, claims: statements, captions: captions, wikitext: wikiText diff --git a/CommonsFinder/Observable Models/UploadManager/MockUploadManager.swift b/CommonsFinder/Observable Models/UploadManager/MockUploadManager.swift index df241ad..91f86f4 100644 --- a/CommonsFinder/Observable Models/UploadManager/MockUploadManager.swift +++ b/CommonsFinder/Observable Models/UploadManager/MockUploadManager.swift @@ -27,47 +27,130 @@ final class MockUploadManager: UploadManager { super.init(appDatabase: appDatabase, accountModel: accountModel) } - private func simulateRegularUpload(_ id: MediaFileDraft.ID) { - print("simulateRegularUpload") - Task { - try? await Task.sleep(for: .milliseconds(100)) - _ = try? setPublishingState(for: id, to: .uploading(0.01)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(0.1)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(0.2)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(0.5)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(0.8)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(1)) - _ = try? setPublishingState(for: id, to: .uploaded(filekey: "aaaa")) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .unstashingFile(filekey: "aaaa")) - try? await Task.sleep(for: .milliseconds(600)) - _ = try? setPublishingState(for: id, to: .creatingWikidataClaims) - try? await Task.sleep(for: .milliseconds(600)) - _ = try? setPublishingState(for: id, to: .published) - try? await Task.sleep(for: .milliseconds(1000)) + private func simulateRegularUpload(_ idType: DraftIDType) { + + switch idType { + case .singleDraft(let id): + print("simulateRegularUpload single \(id)") + Task { + try? await Task.sleep(for: .milliseconds(100)) + _ = try? setPublishingState(for: id, to: .uploading(0.01)) + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .uploading(0.1)) + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .uploading(0.2)) + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .uploading(0.5)) + try? await Task.sleep(for: .milliseconds(1000)) + _ = try? setPublishingState(for: id, to: .uploading(0.8)) + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .uploading(1)) + _ = try? setPublishingState(for: id, to: .uploaded(filekey: "aaaa")) + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .unstashingFile(filekey: "aaaa")) + try? await Task.sleep(for: .milliseconds(600)) + _ = try? setPublishingState(for: id, to: .creatingWikidataClaims) + try? await Task.sleep(for: .milliseconds(600)) + _ = try? setPublishingState(for: id, to: .published) + try? await Task.sleep(for: .milliseconds(1000)) + } + case .multiDraft(let id): + print("simulateRegularUpload multi \(id)") + Task { + var state = MultiDraft.PublishingState(overallProgress: 0.00, isFinished: false, completedCount: 0, totalCount: 4) + + try? await Task.sleep(for: .milliseconds(100)) + state.overallProgress = 0.05 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(500)) + state.overallProgress = 0.1 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(500)) + state.overallProgress = 0.2 + state.completedCount = 1 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(500)) + state.overallProgress = 0.45 + state.completedCount = 2 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(1000)) + state.overallProgress = 0.652 + state.completedCount = 3 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(500)) + state.overallProgress = 1 + state.completedCount = 4 + try? setPublishingState(for: id, updatedState: state) + try? await Task.sleep(for: .milliseconds(500)) + + state.overallProgress = 1 + state.completedCount = state.totalCount + state.isFinished = true + + try? setPublishingState(for: id, updatedState: state) + + } } + + } - private func simulateErrorUpload(_ id: MediaFileDraft.ID) { + private func simulateErrorUpload(_ idType: DraftIDType) { print("simulateErrorUpload") - Task { - try? await Task.sleep(for: .milliseconds(100)) - _ = try? setPublishingState(for: id, to: .uploading(0.01)) - try? await Task.sleep(for: .milliseconds(500)) - _ = try? setPublishingState(for: id, to: .uploading(0.1)) - try? await Task.sleep(for: .milliseconds(1000)) - _ = try? setPublishingError(for: id, error: .uploadWarnings([.existsNormalized(normalizedName: "Some-similar-name.jpeg")])) + switch idType { + case .singleDraft(let id): + Task { + try? await Task.sleep(for: .milliseconds(100)) + _ = try? setPublishingState(for: id, to: .uploading(0.01)) + + try? await Task.sleep(for: .milliseconds(500)) + _ = try? setPublishingState(for: id, to: .uploading(0.1)) + + try? await Task.sleep(for: .milliseconds(1000)) + _ = try? setPublishingError(for: id, error: .uploadWarnings([.existsNormalized(normalizedName: "Some-similar-name.jpeg")])) + } + case .multiDraft(let id): + + guard let uploadables = queuedMultiUploadables[idType] else { return } + + Task { + + var state = MultiDraft.PublishingState(overallProgress: 0.00, isFinished: false, completedCount: 0, totalCount: uploadables.count) + + for uploadable in uploadables { + try? await Task.sleep(for: .milliseconds(1000)) + + if uploadable == uploadables.last { + try? setPublishingState(for: uploadable.id, to: .creatingWikidataClaims) + try? setPublishingError(for: uploadable.id, error: .error(errorDescription: "Some simulated error", recoverySuggestion: "Some suggestion.")) + } else { + try? setPublishingState(for: uploadable.id, to: .published) + } + + state.overallProgress += Double(1) / Double(uploadables.count) + state.completedCount += 1 + try? setPublishingState(for: id, updatedState: state) + } + + state.overallProgress = 1 + try? setPublishingState(for: id, updatedState: state) + + try? await Task.sleep(for: .milliseconds(1000)) + + state.isFinished = true + try? setPublishingState(for: id, updatedState: state) + } } } - override func performUpload(_ id: MediaFileDraft.ID, startStep: API.PublishingStep = .uploadData) { + override func performUpload(_ id: DraftIDType, startStep: API.PublishingStep = .uploadData) { print("perform simulated upload") switch uploadMockSimulation { case .regular: diff --git a/CommonsFinder/Observable Models/UploadManager/UploadManager.swift b/CommonsFinder/Observable Models/UploadManager/UploadManager.swift index c09fc09..f7accec 100644 --- a/CommonsFinder/Observable Models/UploadManager/UploadManager.swift +++ b/CommonsFinder/Observable Models/UploadManager/UploadManager.swift @@ -15,85 +15,16 @@ import UIKit import UniformTypeIdentifiers import os.log -nonisolated enum PublishingError: Equatable, Sendable, CustomStringConvertible, Codable, Hashable { - case twoFactorCodeRequired - case emailCodeRequired - case uploadWarnings([FileUploadResponse.Warning]) - case urlError(urlErrorCode: Int, errorDescription: String) - case error(errorDescription: String?, recoverySuggestion: String?) - case appQuitOrCrash - - var description: String { - switch self { - case .twoFactorCodeRequired: - "twoFactorCodeRequired" - case .emailCodeRequired: - "emailCodeRequired" - case .uploadWarnings(let array): - "uploadWarnings \(array.description)" - case .error(let errorDescription, let recoverySuggestion): - "error \(errorDescription ?? ""), \(recoverySuggestion ?? "")" - case .urlError(let urlErrorCode, let errorDescription): - "urlError \(urlErrorCode) \(errorDescription)" - case .appQuitOrCrash: - "appQuitOrCrash" - } - } - - static func == (lhs: PublishingError, rhs: PublishingError) -> Bool { - lhs.description == rhs.description - } -} - -nonisolated enum PublishingState: Equatable, Sendable, Identifiable, CustomStringConvertible, Codable, Hashable { - case uploading(_ fractionCompleted: Double) - case uploaded(filekey: String) - case unstashingFile(filekey: String) - case creatingWikidataClaims - case published - - var uploadProgress: Double? { - if case .uploading(let fractionCompleted) = self { - fractionCompleted - } else { - nil - } - } - - var id: String { - description - } - - var description: String { - switch self { - case .uploading(let fractionCompleted): - "uploading \(fractionCompleted)" - case .uploaded(let filekey): - "filekey \(filekey)" - case .creatingWikidataClaims: - "creatingWikidataClaims" - case .unstashingFile: - "unstashingFile" - case .published: - "published" - } - } - - static func == (lhs: PublishingState, rhs: PublishingState) -> Bool { - lhs.description == rhs.description - } -} - - @Observable class UploadManager { private let appDatabase: AppDatabase private let accountModel: AccountModel - @ObservationIgnored private var tasks: [MediaFileDraft.ID: Task] - /// uploadable per BGTask identifier - private var queuedUploadables: [MediaFileDraft.ID: MediaFileUploadable] = [:] + @ObservationIgnored private var tasks: [DraftIDType: Task] + + var queuedSingleUploadables: [DraftIDType: MediaFileUploadable] = [:] + var queuedMultiUploadables: [DraftIDType: [MediaFileUploadable]] = [:] /// remember already registed bgTask identifiers, /// to make sure we don't register BGTasks twice during a session (eg. reupload after failed upload), @@ -176,6 +107,8 @@ class UploadManager { try setPublishingState(for: draft.id, to: .unstashingFile(filekey: filekey), verificationRequired: false) case .invalidFilename: try setPublishingState(for: draft.id, to: .unstashingFile(filekey: filekey), verificationRequired: false) + case .none: + assertionFailure() } case .creatingWikidataClaims: // The file is expected to be un-stashed and therefore public, we have to check if the wikidata items have already been created. @@ -201,16 +134,23 @@ class UploadManager { } } } - } + private func updateDraftWithFinalFilename(draft: MediaFileDraft) throws(UploadManagerError) -> MediaFileDraft { + guard !draft.name.isEmpty else { + throw UploadManagerError.nameMissing + } + guard let uniformType = UTType(mimeType: draft.mimeType) else { throw UploadManagerError.missingMimetypePreventedFinalFilenameGeneration } var draft = draft - draft.finalFilename = draft.name.appendingFileExtension(conformingTo: uniformType) + draft.finalFilename = + draft.name + .appendingFileExtension(conformingTo: uniformType) + .precomposedStringWithCanonicalMapping do { return try appDatabase.upsertAndFetch(draft) } catch { @@ -218,13 +158,59 @@ class UploadManager { } } - @discardableResult - func setPublishingState(for draftID: MediaFileDraft.ID, to step: PublishingState?, verificationRequired: Bool = false) throws -> MediaFileDraft { + private func updateDraftsWithFinalFilename(multiDraftInfo: MultiDraftInfo) throws(UploadManagerError) -> MultiDraftInfo { + guard !multiDraftInfo.multiDraft.name.isEmpty else { + throw UploadManagerError.nameMissing + } + + let finalFilenames: [MediaFileDraft.ID: String] + do { + finalFilenames = try FilenameUtils.generateMultiDraftFinalFilenames(multiDraftInfo: multiDraftInfo) + } catch { + throw .failedToGenerateFilenameForMultiUpload + } + + for draft in multiDraftInfo.drafts { + var draft = draft + guard let finalFilename = finalFilenames[draft.id] else { + throw .failedToGenerateIndividualFilenameForMultiUpload + } + + do { + draft.finalFilename = finalFilename + _ = try appDatabase.upsert(draft) + } catch { + throw .databaseErrorOnFinalFilenameUpdate(error) + } + } + + let updatedMultiDraftInfo: MultiDraftInfo? + do { + updatedMultiDraftInfo = try appDatabase.fetchMultiDraftInfo(id: multiDraftInfo.id) + } catch { + throw .databaseErrorOnFinalFilenameUpdate(error) + } + + + guard let updatedMultiDraftInfo else { + throw .emptyMultiDraftInfoAfterUpdatingFilenames + } + + return updatedMultiDraftInfo + + + } + + + func setPublishingState(for draftID: MediaFileDraft.ID, to step: MediaFileDraft.PublishingState?, verificationRequired: Bool = false) throws { try appDatabase.updateDraft(id: draftID, withPublishingStep: step, verificationRequired: verificationRequired) } - @discardableResult - func setPublishingError(for draftID: MediaFileDraft.ID, error: PublishingError?) throws -> MediaFileDraft { + func setPublishingState(for multiDraftID: MultiDraft.ID, updatedState: MultiDraft.PublishingState?) throws { + try appDatabase.updateMultiDraft(id: multiDraftID, withPublishingStep: updatedState) + } + + func setPublishingError(for draftID: MediaFileDraft.ID, error: MediaFileDraft.PublishingError?) throws { try appDatabase.updateDraft(id: draftID, withPublishingError: error) } /// upload a MediaFileDraft (or resume a previously interrupted upload) @@ -242,22 +228,73 @@ class UploadManager { } } - private func upload(_ draft: MediaFileDraft, username: String, startStep: API.PublishingStep) { + func upload(_ multiDraftInfo: MultiDraftInfo, username: String) { + var multiDraftInfo = multiDraftInfo + + guard let multiDraftID = multiDraftInfo.id else { + assertionFailure("We expect the draft to be already stored in the DB before uploading.") + return + } + + do { + multiDraftInfo = try updateDraftsWithFinalFilename(multiDraftInfo: multiDraftInfo) + } catch { + logger.error("Failed to set names of multi draft \(error)") + } + + var uploadables: [MediaFileUploadable] = [] + + for draft in multiDraftInfo.drafts { + do { + try draft.updateExifLocation() + let uploadable = try MediaFileUploadable.init(draft, multiDraft: multiDraftInfo.multiDraft, appWikimediaUsername: username) + uploadables.append(uploadable) + + assert( + uploadable.id == draft.id, + "We expect the MediaFileDraft in the DB the temporary MediaFileUploadable to have the same ID" + ) + } catch (.databaseErrorOnFinalFilenameUpdate(let error)) { + logger.error("Failed to update draft in SQL DB with final filename! \(error)") + } catch (.missingMimetypePreventedFinalFilenameGeneration) { + logger.error("Failed to create uploadable because the final filename with file-ending (eg. .jpg) could be be generated because the mimeType is unknown") + } catch (.fileURLMissing) { + logger.error("Failed to create uploadable because fileURL field is missing") + } catch (.onlyDraftsCanBeUploaded) { + logger.error("Failed to create uploadable because it must be a local draft.") + } catch (.failedToOverwriteExifLocation(let error)) { + logger.error("Failed to overwrite exif location \(error)") + } catch { + // Swift 6.0 compiler correctly produces warning: “Case will never be executed” + // retry in XCode 16.3-4 + // see: https://github.com/swiftlang/swift/issues/74555 + logger.error("this is required to silence 'non-exhaustive' error, but generates a 'will never be executed' warning") + } + } + + let id = DraftIDType.multiDraft(multiDraftID) + queuedMultiUploadables[id] = uploadables + performUpload(id) + } + + func upload(_ draft: MediaFileDraft, username: String, startStep: API.PublishingStep) { // TODO: check auth here instead of failing later, so the upload isn't officially started yet // .... try ensureUserIsLoggedIn() // throwing re-auth required + do { try draft.updateExifLocation() let finalDraft = try updateDraftWithFinalFilename(draft: draft) + let id = DraftIDType.singleDraft(finalDraft.id) let uploadable = try MediaFileUploadable.init(finalDraft, appWikimediaUsername: username) - queuedUploadables[draft.id] = uploadable + queuedSingleUploadables[id] = uploadable assert( - uploadable.id == draft.id, + uploadable.id == finalDraft.id, "We expect the MediaFileDraft in the DB the temporary MediaFileUploadable to have the same ID" ) - performUpload(draft.id, startStep: startStep) + performUpload(id, startStep: startStep) } catch (.databaseErrorOnFinalFilenameUpdate(let error)) { logger.error("Failed to update draft in SQL DB with final filename! \(error)") @@ -277,53 +314,82 @@ class UploadManager { } } - func performUpload(_ id: MediaFileDraft.ID, startStep: API.PublishingStep = .uploadData) { + // FIXME: !!!!! reconsider "startStep" usage + + func performUpload(_ id: DraftIDType, startStep: API.PublishingStep = .uploadData) { if #available(iOS 26.0, *) { performUploadWithBGTask(id: id, startStep: startStep) } else { - performUploadImpl(id: id, startStep: startStep) + switch id { + case .singleDraft(let iD): + performSingleUploadImpl(id: id, startStep: startStep) + case .multiDraft(let multiDraftID): + performMultiUploadImpl(id: id) + } } } @available(iOS 26.0, *) - private func performUploadWithBGTask(id: MediaFileDraft.ID, startStep: API.PublishingStep = .uploadData) { + private func performUploadWithBGTask(id: DraftIDType, startStep: API.PublishingStep = .uploadData) { let bgTaskScheduler = BGTaskScheduler.shared let bgTaskIdentifier = "app.CommonsFinder.upload.\(id)" if !registeredBGTaskIDs.contains(bgTaskIdentifier) { + let didRegister = bgTaskScheduler.register(forTaskWithIdentifier: bgTaskIdentifier, using: .main) { [self] bgTask in guard let bgTask = bgTask as? BGContinuedProcessingTask else { return } bgTask.expirationHandler = { self.tasks[id]?.cancel() } - performUploadImpl(id: id, startStep: startStep, bgTask: bgTask) + + switch id { + case .singleDraft(_): + performSingleUploadImpl(id: id, startStep: startStep, bgTask: bgTask) + case .multiDraft(_): + performMultiUploadImpl(id: id, bgTask: bgTask) + } } + guard didRegister else { - logger.error("Failed to register BG task handler for \(bgTaskIdentifier). Falling back to immediate upload.") - performUploadImpl(id: id, startStep: startStep) + logger.error("Failed to register BG task handler for \(bgTaskIdentifier). Falling back to upload without bgTask.") + + switch id { + case .singleDraft(_): + performSingleUploadImpl(id: id, startStep: startStep) + case .multiDraft(_): + performMultiUploadImpl(id: id) + } + assertionFailure() return } + registeredBGTaskIDs.insert(bgTaskIdentifier) } let bgRequest = BGContinuedProcessingTaskRequest( identifier: bgTaskIdentifier, - title: "Uploading a File", + title: id.isMultiDraft ? "Uploading files" : "Uploading a file", subtitle: "About to start...", ) + bgRequest.strategy = .queue do { try bgTaskScheduler.submit(bgRequest) } catch { logger.error("Failed to submit BG task request for \(bgTaskIdentifier): \(error). Falling back to immediate upload.") - performUploadImpl(id: id, startStep: startStep) + switch id { + case .singleDraft(_): + performSingleUploadImpl(id: id, startStep: startStep) + case .multiDraft(_): + performMultiUploadImpl(id: id) + } } } - private func performUploadImpl(id: MediaFileDraft.ID, startStep: API.PublishingStep = .uploadData, bgTask: BGTask? = nil) { - guard let uploadable = queuedUploadables[id] else { + private func performSingleUploadImpl(id: DraftIDType, startStep: API.PublishingStep = .uploadData, bgTask: BGTask? = nil) { + guard let uploadable = queuedSingleUploadables[id] else { assertionFailure() return } @@ -337,11 +403,10 @@ class UploadManager { bgTask.progress.completedUnitCount = 0 } - tasks[id] = Task { defer { tasks[id] = nil - queuedUploadables[id] = nil + queuedSingleUploadables[id] = nil logger.debug("Cleanup up queuedUploadables and tasks for \(id) after task finished. bgTask identifier: \(bgTask?.identifier ?? "no BGTask")") } @@ -352,13 +417,13 @@ class UploadManager { case .twoFactorCodeRequired: // FIXME: actual trigger a re-login when auth failed (eg. due to 2fa, or password change) // and ability to retry/resume - try setPublishingError(for: id, error: .twoFactorCodeRequired) + try setPublishingError(for: uploadable.id, error: .twoFactorCodeRequired) bgTask?.setTaskCompleted(success: false) return case .emailCodeRequired: // FIXME: actual trigger a re-login when auth failed (eg. due to 2fa, or password change) // and ability to retry/resume - try setPublishingError(for: id, error: .emailCodeRequired) + try setPublishingError(for: uploadable.id, error: .emailCodeRequired) bgTask?.setTaskCompleted(success: false) return case .tokenReceived(let token): @@ -367,7 +432,7 @@ class UploadManager { } catch { logger.error("failed to fetch CSRF token for upload: \(error)") bgTask?.setTaskCompleted(success: false) - try setPublishingError(for: id, error: .error(errorDescription: error.localizedDescription, recoverySuggestion: "Check if you are logged in to your Wikimedia Account.")) + try setPublishingError(for: uploadable.id, error: .error(errorDescription: error.localizedDescription, recoverySuggestion: "Check if you are logged in to your Wikimedia Account.")) return } @@ -382,7 +447,7 @@ class UploadManager { switch status { case .uploadingFile(let progress): - _ = try? setPublishingState(for: id, to: .uploading(progress.fractionCompleted)) + _ = try? setPublishingState(for: uploadable.id, to: .uploading(progress.fractionCompleted)) if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { let percentCompleted = Int64(progress.fractionCompleted * 100) bgTask.updateTitle( @@ -392,10 +457,10 @@ class UploadManager { bgTask.progress.completedUnitCount = percentCompleted } case .fileKeyObtained(let filekey): - _ = try? setPublishingState(for: id, to: .uploaded(filekey: filekey)) + _ = try? setPublishingState(for: uploadable.id, to: .uploaded(filekey: filekey)) case .unstashingFile(let filekey): - _ = try? setPublishingState(for: id, to: .unstashingFile(filekey: filekey), verificationRequired: true) + _ = try? setPublishingState(for: uploadable.id, to: .unstashingFile(filekey: filekey), verificationRequired: true) if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask @@ -405,14 +470,14 @@ class UploadManager { } case .creatingWikidataClaims: - _ = try? setPublishingState(for: id, to: .creatingWikidataClaims, verificationRequired: true) + _ = try? setPublishingState(for: uploadable.id, to: .creatingWikidataClaims, verificationRequired: true) if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { bgTask.progress.completedUnitCount += 5 bgTask.updateTitle(bgTask.title, subtitle: "creating metadata...") } case .published: - _ = try? setPublishingState(for: id, to: .published) + _ = try? setPublishingState(for: uploadable.id, to: .published) if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { @@ -421,26 +486,27 @@ class UploadManager { bgTask.setTaskCompleted(success: true) } - cleanupDraftAfterPublished(id: id) + cleanupDraftAfterPublished(ids: [uploadable.id]) case .uploadWarnings(let warnings): if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) } - _ = try? setPublishingError(for: id, error: .uploadWarnings(warnings)) + _ = try? setPublishingError(for: uploadable.id, error: .uploadWarnings(warnings)) case .urlError(let urlError): if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) } - _ = try? setPublishingError(for: id, error: .urlError(urlErrorCode: urlError.errorCode, errorDescription: String(describing: urlError))) + _ = try? setPublishingError(for: uploadable.id, error: .urlError(urlErrorCode: urlError.errorCode, errorDescription: String(describing: urlError))) case .unspecifiedError(let error): - _ = try? setPublishingError(for: id, error: .error(errorDescription: String(describing: error), recoverySuggestion: nil)) + _ = try? setPublishingError(for: uploadable.id, error: .error(errorDescription: String(describing: error), recoverySuggestion: nil)) if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) } case .fileKeyMissingAfterUpload: _ = try? setPublishingError( - for: id, error: .error(errorDescription: "The required \"filekey\" was missing after the upload. This indicates bad response data from the server.", recoverySuggestion: "")) + for: uploadable.id, + error: .error(errorDescription: "The required \"filekey\" was missing after the upload. This indicates bad response data from the server.", recoverySuggestion: "")) if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) } @@ -449,8 +515,144 @@ class UploadManager { } } + private func performMultiUploadImpl(id: DraftIDType, bgTask: BGTask? = nil) { + assert(id.isMultiDraft, "We expect a multi draft ID") + let multiDraftID = id.multiDraftID + guard let uploadables = queuedMultiUploadables[id] else { return } + assert(uploadables.count > 1, "We expect to have multiple uploadables for this id.") + + var publishingState: MultiDraft.PublishingState = .init( + overallProgress: 0, + isFinished: false, + completedCount: 0, + totalCount: uploadables.count + ) + + var encounteredErrors = false + + if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { + bgTask.progress.totalUnitCount = 100 + bgTask.progress.completedUnitCount = 0 + } + + + tasks[id] = Task { + defer { + tasks[id] = nil + queuedMultiUploadables[id] = nil + logger.debug("Cleanup up queuedUploadables and tasks for \(id) after task finished. bgTask identifier: \(bgTask?.identifier ?? "no BGTask")") + } + + for uploadable in uploadables { + defer { publishingState.completedCount += 1 } + + try? setPublishingState(for: multiDraftID, updatedState: publishingState) - private func cleanupDraftAfterPublished(id: MediaFileDraft.ID) { + let csrfToken: String + do { + let tokenAuthResult = try await Authentication.fetchCSRFToken() + switch tokenAuthResult { + case .twoFactorCodeRequired: + // FIXME: actual trigger a re-login when auth failed (eg. due to 2fa, or password change) + // and ability to retry/resume + try setPublishingError(for: uploadable.id, error: .twoFactorCodeRequired) + bgTask?.setTaskCompleted(success: false) + return + case .emailCodeRequired: + // FIXME: actual trigger a re-login when auth failed (eg. due to 2fa, or password change) + // and ability to retry/resume + try setPublishingError(for: uploadable.id, error: .emailCodeRequired) + bgTask?.setTaskCompleted(success: false) + return + case .tokenReceived(let token): + csrfToken = token + } + } catch { + logger.error("failed to fetch CSRF token for upload: \(error)") + bgTask?.setTaskCompleted(success: false) + try setPublishingError(for: uploadable.id, error: .error(errorDescription: error.localizedDescription, recoverySuggestion: "Check if you are logged in to your Wikimedia Account.")) + return + } + + let request = await Networking.shared.api.publish(file: uploadable, csrfToken: csrfToken, startStep: .uploadData) + + for await status in request { + guard !Task.isCancelled else { + bgTask?.setTaskCompleted(success: false) + return + } + + if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { + bgTask.updateTitle( + bgTask.title, + subtitle: "File \(publishingState.completedCount + 1)/\(publishingState.totalCount)" + ) + } + + switch status { + case .uploadingFile(let progress): + _ = try? setPublishingState( + for: uploadable.id, + to: .uploading(progress.fractionCompleted) + ) + + publishingState.overallProgress = + Double(Int(publishingState.completedCount) / publishingState.totalCount) + Double(progress.fractionCompleted / Double(publishingState.totalCount)) + + case .fileKeyObtained(let filekey): + _ = try? setPublishingState(for: uploadable.id, to: .uploaded(filekey: filekey)) + case .unstashingFile(let filekey): + _ = try? setPublishingState(for: uploadable.id, to: .unstashingFile(filekey: filekey), verificationRequired: true) + case .creatingWikidataClaims: + _ = try? setPublishingState(for: uploadable.id, to: .creatingWikidataClaims, verificationRequired: true) + case .published: + _ = try? setPublishingState(for: uploadable.id, to: .published) + case .uploadWarnings(let warnings): + encounteredErrors = true + _ = try? setPublishingError(for: uploadable.id, error: .uploadWarnings(warnings)) + case .urlError(let urlError): + encounteredErrors = true + _ = try? setPublishingError(for: uploadable.id, error: .urlError(urlErrorCode: urlError.errorCode, errorDescription: String(describing: urlError))) + case .unspecifiedError(let error): + encounteredErrors = true + _ = try? setPublishingError(for: uploadable.id, error: .error(errorDescription: String(describing: error), recoverySuggestion: nil)) + case .fileKeyMissingAfterUpload: + encounteredErrors = true + _ = try? setPublishingError( + for: uploadable.id, + error: .error(errorDescription: "The required \"filekey\" was missing after the upload. This indicates bad response data from the server.", recoverySuggestion: "")) + } + + try? setPublishingState(for: multiDraftID, updatedState: publishingState) + if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { + bgTask.progress.completedUnitCount = Int64(publishingState.overallProgress) + } + } + } + + publishingState.completedCount = publishingState.totalCount + publishingState.overallProgress = 100 + publishingState.isFinished = true + try? setPublishingState(for: multiDraftID, updatedState: publishingState) + + if #available(iOS 26.0, *), let bgTask = bgTask as? BGContinuedProcessingTask { + bgTask.progress.completedUnitCount = Int64(publishingState.overallProgress) + } + + if encounteredErrors || publishingState.completedCount != publishingState.totalCount { + // For multi-drafts we don't clean up the individual drafts if some failed, + // so the user can review which were succesful or not in the detailed multi-draft list overview. + bgTask?.setTaskCompleted(success: false) + } else { + cleanupDraftAfterPublished(ids: uploadables.map(\.id)) + bgTask?.setTaskCompleted(success: true) + } + + } + } + + + private func cleanupDraftAfterPublished(ids: [MediaFileDraft.ID]) { accountModel.syncUserData() Task { @@ -458,7 +660,7 @@ class UploadManager { try? await Task.sleep(for: .milliseconds(2000)) do { - let deletedFileCount = try appDatabase.deleteDrafts(ids: [id]) + let deletedFileCount = try appDatabase.deleteDrafts(ids: ids) if deletedFileCount != 0 { logger.info("Deleted \(deletedFileCount) drafts that have been uploaded.") } @@ -467,6 +669,7 @@ class UploadManager { } } } + } extension MediaFileDraft { @@ -535,12 +738,16 @@ extension MediaFileDraft { enum UploadManagerError: Error { case onlyDraftsCanBeUploaded(id: String) case fileURLMissing(id: String) + case nameMissing case finalFilenameMissing + case failedToGenerateFilenameForMultiUpload + case failedToGenerateIndividualFilenameForMultiUpload case licenseMissing case sourceMissing case authorMissing case missingMimetypePreventedFinalFilenameGeneration case databaseErrorOnFinalFilenameUpdate(Error) + case emptyMultiDraftInfoAfterUpdatingFilenames case failedToReadFileData case failedToOverwriteExifLocation(Error? = nil) } diff --git a/CommonsFinder/Types/DraftIDType.swift b/CommonsFinder/Types/DraftIDType.swift new file mode 100644 index 0000000..8a96cec --- /dev/null +++ b/CommonsFinder/Types/DraftIDType.swift @@ -0,0 +1,43 @@ +// +// DraftIDType.swift +// CommonsFinder +// +// Created by Tom Brewe on 27.04.26. +// + +import Foundation + +enum DraftIDType: Hashable, Identifiable, Equatable, CustomStringConvertible { + case singleDraft(MediaFileDraft.ID) + case multiDraft(MultiDraft.MultiDraftID) + + var id: String { + switch self { + case .singleDraft(let id): id + case .multiDraft(let id): String(id) + } + } + + var multiDraftID: Int64? { + switch self { + case .singleDraft(_): nil + case .multiDraft(let id): id + } + } + + var isMultiDraft: Bool { + switch self { + case .singleDraft(_): false + case .multiDraft(_): true + } + } + + var description: String { + switch self { + case .singleDraft(let id): + id + case .multiDraft(let multiDraftID): + String(multiDraftID) + } + } +} diff --git a/CommonsFinder/Types/MediaFileDraft+uploadDisabledReason.swift b/CommonsFinder/Types/MediaFileDraft+uploadDisabledReason.swift deleted file mode 100644 index e831f5e..0000000 --- a/CommonsFinder/Types/MediaFileDraft+uploadDisabledReason.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// File.swift -// CommonsFinder -// -// Created by Tom Brewe on 09.01.26. -// - -import CommonsAPI -import UniformTypeIdentifiers -import os.log - -extension MediaFileDraftModel { - func canUploadDraft() -> UploadPossibleStatus? { - return - if draft.captionWithDesc.isEmpty - || draft.captionWithDesc.allSatisfy({ captionDesc in - captionDesc.caption.isEmpty && captionDesc.fullDescription.isEmpty - }) - { - .missingCaptionOrDescription - } else if draft.tags.isEmpty { - .missingTags - } else if draft.license == nil { - .missingLicense - } else if let nameValidationResult { - switch nameValidationResult { - case .failure(let nameValidationError): - .validationError(nameValidationError) - case .success(_): - .uploadPossible - } - } else { - nil - } - } - - func validateFilename() async -> NameValidationResult { - let localValidationResult = LocalFileNameValidation.validateFileName(draft.name) - return switch localValidationResult { - case .success: - // if local validation was successful, check again with API - await validateFilenameWithAPI() ?? .success(()) - case .failure(let error): - .failure(.invalid(error)) - } - } - - private func validateFilenameWithAPI() async -> NameValidationResult? { - // iOS26 target: move into an Observation on draft.name - guard let uniformType = UTType(mimeType: draft.mimeType) else { - assertionFailure("We expect drafts to always have a correct mimetype") - return nil - } - - - // The API operates on filenames with type-endings (.jpg, .png, etc.) - let filename = draft.name.appendingFileExtension(conformingTo: uniformType) - - do { - async let existsTask = Networking.shared.api.checkIfFileExists( - filename: filename - ) - async let validationTask = Networking.shared.api.validateFilename( - filename: filename - ) - - let (existsResult, validationResult) = try await (existsTask, validationTask) - - switch existsResult { - case .exists: return .failure(.alreadyExists) - case .invalidFilename: return .failure(.invalid(nil)) - case .doesNotExist: - switch validationResult { - case .disallowed: return .failure(.disallowed) - case .invalid: return .failure(.invalid(nil)) - case .ok: return .success(()) - case .unknownOther: return .failure(.undefinedAPIResult) - } - } - } catch is CancellationError { - return nil - } catch { - logger.error("Failed to validate filename \(error)") - return .failure(.undefinedAPIResult) - } - } -} diff --git a/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift b/CommonsFinder/Types/NameValidationError.swift similarity index 57% rename from CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift rename to CommonsFinder/Types/NameValidationError.swift index 1ad8bea..555d952 100644 --- a/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift +++ b/CommonsFinder/Types/NameValidationError.swift @@ -1,106 +1,15 @@ // -// MediaFileDraftModel.swift +// NameValidationError.swift // CommonsFinder // -// Created by Tom Brewe on 13.10.24. +// Created by Tom Brewe on 11.03.26. // -import CommonsAPI -import CoreLocation import Foundation -import Nuke -import UniformTypeIdentifiers -import os.log - -/// Represents the data to allow editing either a DB-backed MediaFile or a newly created one. -@Observable final class MediaFileDraftModel: @preconcurrency Identifiable { - typealias ID = String - var id: ID - var draft: MediaFileDraft - let addedDate: Date - - var isShowingTagsPicker = false - var isShowingCategoryPicker = false - - var suggestedFilenames: [FileNameTypeTuple] = [] - var nameValidationResult: NameValidationResult? - - /// If a draft has just been created and does not have its media file backed on disk in the apps directory - /// this holds the information about filename, filetype and Data. - var fileItem: FileItem? - - @ObservationIgnored - lazy var exifData: ExifData? = { - draft.loadExifData() - }() - - init(fileItem: FileItem, newDraftOptions: NewDraftOptions?) throws { - addedDate = .now - var draft = try MediaFileDraft(fileItem) - if let initialTag = newDraftOptions?.tag { - draft.tags = [initialTag] - } - self.id = fileItem.id - self.draft = draft - self.fileItem = fileItem - } - - /// Use an already fully initialized draft - init(existingDraft: MediaFileDraft) { - addedDate = .now - id = existingDraft.id - draft = existingDraft - } - - var choosenCoordinate: CLLocationCoordinate2D? { - return switch draft.locationHandling { - case .userDefinedLocation(latitude: let lat, longitude: let lon, _): - .init(latitude: lat, longitude: lon) - case .exifLocation: - exifData?.coordinate - case .noLocation: - nil - case .none: - nil - } - - } - - func validateFilenameImpl() async throws { - nameValidationResult = nil - draft.uploadPossibleStatus = nil - try await Task.sleep(for: .milliseconds(500)) - nameValidationResult = await validateFilename() - draft.uploadPossibleStatus = canUploadDraft() - } -} - -typealias NameValidationResult = Result - -extension NameValidationResult { - var error: NameValidationError? { - switch self { - case .success: return nil - case .failure(let error): return error - } - } - - var alertTitle: String? { - if let error { - switch error { - case .invalid(_): - error.failureReason - default: - error.errorDescription - } - } else { - nil - } - } -} +import SwiftUI enum NameValidationError: LocalizedError, Codable, Hashable, Equatable { - case alreadyExists + case alreadyExists(filenames: [String]) case disallowed case invalid(LocalFilenameValidationError?) case undefinedAPIResult @@ -121,7 +30,7 @@ enum NameValidationError: LocalizedError, Codable, Hashable, Equatable { var failureReason: String? { switch self { - case .alreadyExists: + case .alreadyExists(let filenames): String(localized: "filenames must be unique on the server") case .disallowed: String(localized: "some words or combinations of characters have been blocked on the server either because they are to generic and non-descript or due to other reasons.") @@ -139,6 +48,8 @@ enum NameValidationError: LocalizedError, Codable, Hashable, Equatable { String(localized: "The filename contains extra spaces at the start or end") case .none: String(localized: "Certain characters are reserved due to technical reasons and cannot be used for file names") + case .disallowedMimetype: + String(localized: "The file type is not supported.") } case .undefinedAPIResult: @@ -164,6 +75,8 @@ enum NameValidationError: LocalizedError, Codable, Hashable, Equatable { String(localized: "Please choose a descriptive and meaningful filename.") case .leadingTrailingSpaces: String(localized: "Please remove the extra spaces from the filename.") + case .disallowedMimetype: + String(localized: "The file must be converted to a supported format before uploading.") case nil: String(localized: "Please choose a different filename.") diff --git a/CommonsFinder/Types/UploadPossibleStatus.swift b/CommonsFinder/Types/UploadPossibleStatus.swift index a816469..3f06aaa 100644 --- a/CommonsFinder/Types/UploadPossibleStatus.swift +++ b/CommonsFinder/Types/UploadPossibleStatus.swift @@ -10,7 +10,6 @@ import Foundation nonisolated enum UploadPossibleStatus: Codable, Equatable, Hashable { case uploadPossible - case notLoggedIn case missingCaptionOrDescription case missingLicense case missingTags diff --git a/CommonsFinder/Utilities/ExifData.swift b/CommonsFinder/Utilities/ExifData.swift index f848052..47d6829 100644 --- a/CommonsFinder/Utilities/ExifData.swift +++ b/CommonsFinder/Utilities/ExifData.swift @@ -96,7 +96,7 @@ nonisolated struct ExifData: Codable, Equatable, Hashable { /// and test with front/back cam for accuracy of angle private var destBearing: Double? - var normalizedBearing: Double? { + var normalizedBearing: CLLocationDegrees? { if let destBearing { GeoVectorMath.normalizeBearing(degrees: destBearing) } else { diff --git a/CommonsFinder/Utilities/FileAnalysisHelpers.swift b/CommonsFinder/Utilities/FileAnalysisHelpers.swift index 7c2af52..ee9e382 100644 --- a/CommonsFinder/Utilities/FileAnalysisHelpers.swift +++ b/CommonsFinder/Utilities/FileAnalysisHelpers.swift @@ -19,6 +19,17 @@ import os.log nonisolated enum FileAnalysisHelpers { typealias RadiusFetchOperation = (_ coordinate: CLLocationCoordinate2D, _ kilometerRadius: CLLocationDistance, _ limit: Int) async throws -> [Category] + @concurrent static func analyze(coordinate: CLLocationCoordinate2D, horizontalError: CLLocationDistance?, bearing: CLLocationDegrees?, appDatabase: AppDatabase) async -> ImageAnalysisResult? { + let categories = await fetchNearbyCategories( + coordinate: coordinate, + horizontalError: horizontalError, + bearing: bearing, + appDatabase: appDatabase + ) + + return .init(nearbyCategories: categories) + } + @concurrent static func analyze(mediaFile: MediaFile, appDatabase: AppDatabase) async -> ImageAnalysisResult? { let coordinate: CLLocationCoordinate2D? = mediaFile.coordinate if coordinate == nil { diff --git a/CommonsFinder/Utilities/FilenameUtils.swift b/CommonsFinder/Utilities/FilenameUtils.swift new file mode 100644 index 0000000..c62faca --- /dev/null +++ b/CommonsFinder/Utilities/FilenameUtils.swift @@ -0,0 +1,45 @@ +// +// FilenameUtils.swift +// CommonsFinder +// +// Created by Tom Brewe on 13.05.26. +// + +import Foundation +import UniformTypeIdentifiers + +nonisolated enum FilenameUtils { + static func generateMultiDraftFinalFilenames(multiDraftInfo: MultiDraftInfo) throws -> [MediaFileDraft.ID: String] { + + let baseName = multiDraftInfo.multiDraft.name + var fileNumber = 1 + let digitCount = Int(floor(log10(Double(multiDraftInfo.drafts.count)) + 1)) + + var resultNames: [MediaFileDraft.ID: String] = [:] + + for draft in multiDraftInfo.drafts { + guard let uniformType = UTType(mimeType: draft.mimeType) else { + throw UploadManagerError.missingMimetypePreventedFinalFilenameGeneration + } + + var finalFilename = + switch multiDraftInfo.multiDraft.nameSuffix { + case .numberingZeroPadded: + baseName + ", \(String(format: "%0\(digitCount)d", fileNumber))" + case .numbering: + baseName + ", \(fileNumber)" + } + finalFilename = + finalFilename + .appendingFileExtension(conformingTo: uniformType) + .precomposedStringWithCanonicalMapping + + resultNames[draft.id] = finalFilename + fileNumber += 1 + } + + return resultNames + + + } +} diff --git a/CommonsFinder/Utilities/ValidationUtils.swift b/CommonsFinder/Utilities/ValidationUtils.swift index 9d92dda..449fe59 100644 --- a/CommonsFinder/Utilities/ValidationUtils.swift +++ b/CommonsFinder/Utilities/ValidationUtils.swift @@ -5,7 +5,158 @@ // Created by Tom Brewe on 19.11.24. // +import CommonsAPI import Foundation +import UniformTypeIdentifiers +import os.log + +nonisolated enum ValidationError: Error { + case emptyArray +} + +nonisolated enum DraftValidation { + static func canUploadDraft(_ draft: some Draftable, nameValidationResult: NameValidationResult?) -> UploadPossibleStatus? { + return + if draft.captionWithDesc.isEmpty + || draft.captionWithDesc.allSatisfy({ captionDesc in + captionDesc.caption.isEmpty && captionDesc.fullDescription.isEmpty + }) + { + .missingCaptionOrDescription + } else if draft.tags.isEmpty { + .missingTags + } else if draft.license == nil { + .missingLicense + } else if let nameValidationResult { + switch nameValidationResult { + case .failure(let nameValidationError): + .validationError(nameValidationError) + case .success(_): + .uploadPossible + } + } else { + nil + } + } + + static func validateFilename(name: String, mimeType: String) async -> NameValidationResult { + // iOS26 target: move into an Observation on draft.name + guard let uniformType = UTType(mimeType: mimeType) else { + assertionFailure("We expect drafts to always have a correct mimetype") + return .failure(.invalid(.disallowedMimetype)) + } + + // The API operates on filenames with type-endings (.jpg, .png, etc.) + let filename = name.appendingFileExtension(conformingTo: uniformType) + + let localValidationResult = LocalFileNameValidation.validateFilenameWithSuffix(filename) + return switch localValidationResult { + case .success: + // if local validation was successful, check again with API + await validateFilenameWithAPI(filename) ?? .success(()) + case .failure(let error): + .failure(.invalid(error)) + } + } + + + /// validates multiple filenames that are expected to be similar in structure and only vary by counter, eg. adding 01, 02. (without "File:"-prefix, but with the filetype suffix) + @concurrent + static func validateBatchFilenames(filenamesWithSuffix: [String]) async throws -> NameValidationResult { + var localValidation: [String: Result] = .init() + + for filename in filenamesWithSuffix { + localValidation[filename] = LocalFileNameValidation.validateFilenameWithSuffix(filename) + } + + let allNamesPassedLocally = localValidation.values.allSatisfy({ result in + if case .success() = result { true } else { false } + }) + + guard allNamesPassedLocally else { + let failures: [LocalFilenameValidationError] = localValidation.values.compactMap { validation in + switch validation { + case .success(_): nil + case .failure(let error): error + } + } + return .failure(.invalid(failures.first)) + } + + guard let firstName = filenamesWithSuffix.first else { + throw ValidationError.emptyArray + } + + // NOTE: we only check one file name for performance reasons against the blocklist. + // there is no batch-call for this. We usually expect that if the first item passes (eg. "name, 01.jpeg"), all other + // names in the list are fine ("name, 02.jpeg", "name, 99.jpeg" etc) + let apiBlocklistValidation = try await Networking.shared.api.validateFilename(filename: firstName) + + guard apiBlocklistValidation == .ok else { + return .failure(apiBlocklistValidation == .disallowed ? .disallowed : .invalid(nil)) + } + + var existsAPIValidation: [String: FilenameExistsResult] = .init() + existsAPIValidation = try await Networking.shared.api.checkIfFilesExists(filenames: filenamesWithSuffix) + + + let allNamesPassedInAPI = existsAPIValidation.values.allSatisfy({ result in + if case .doesNotExist = result { true } else { false } + }) + + if allNamesPassedInAPI { + return .success(()) + } else if existsAPIValidation.values.contains(.exists) { + let alreadyExistsNames: [String] = existsAPIValidation.compactMap { (name, validation) in + switch validation { + case .exists: name + default: nil + } + } + return .failure(.alreadyExists(filenames: alreadyExistsNames)) + } else if existsAPIValidation.values.contains(.invalidFilename) { + return .failure(.disallowed) + } else { + assertionFailure("Likely added new cases to `FilenameExistsResult`") + return .failure(.undefinedAPIResult) + } + } + + /// validates a filename (without "File:"-prefix & without file-type suffix (eg. .jpg) + private static func validateFilenameWithAPI(_ fileNameWithMimetypeSuffix: String) async -> NameValidationResult? { + let filename = fileNameWithMimetypeSuffix + + do { + async let existsTask = Networking.shared.api.checkIfFileExists( + filename: filename + ) + async let validationTask = Networking.shared.api.validateFilename( + filename: filename + ) + + let (existsResult, validationResult) = try await (existsTask, validationTask) + + switch existsResult { + case .exists: return .failure(.alreadyExists(filenames: [filename])) + case .invalidFilename: return .failure(.invalid(nil)) + case .doesNotExist: + switch validationResult { + case .disallowed: return .failure(.disallowed) + case .invalid: return .failure(.invalid(nil)) + case .ok: return .success(()) + case .unknownOther: return .failure(.undefinedAPIResult) + } + case .none: + return .failure(.undefinedAPIResult) + } + } catch is CancellationError { + return nil + } catch { + logger.error("Failed to validate filename \(error)") + return .failure(.undefinedAPIResult) + } + } +} nonisolated enum EmailValidation { static func isValidEmailAddress(string: String) -> Bool { @@ -56,13 +207,15 @@ nonisolated enum LocalFileNameValidation { /^(.)\1*$/ } - /// valiidates filenames without "FILE:" prefix, and without filetype sufix (eg. ".jpg"), for - static func validateFileName(_ filename: String) -> Result { - guard filename.count >= minCharLength else { return .failure(.tooShort) } - guard !filename.contains(disallowedPrefixPattern) else { return .failure(.disallowedPrefix) } - guard !filename.contains(disallowedCharactersPattern) else { return .failure(.disallowedCharacters) } - guard !filename.localizedLowercase.contains(onlyRepeatingCharactersPattern) else { return .failure(.onlyRepeatingCharacters) } - guard !filename.contains(leadingTrailingSpacesPattern) else { return .failure(.leadingTrailingSpaces) } + /// validates filenames without "File:" prefix. Expected to already have the mimetype suffix + static func validateFilenameWithSuffix(_ fileNameWithMimetypeSuffix: String) -> Result { + let baseName = (fileNameWithMimetypeSuffix as NSString).deletingPathExtension + + guard baseName.count >= minCharLength else { return .failure(.tooShort) } + guard !baseName.contains(disallowedPrefixPattern) else { return .failure(.disallowedPrefix) } + guard !baseName.contains(disallowedCharactersPattern) else { return .failure(.disallowedCharacters) } + guard !baseName.localizedLowercase.contains(onlyRepeatingCharactersPattern) else { return .failure(.onlyRepeatingCharacters) } + guard !baseName.contains(leadingTrailingSpacesPattern) else { return .failure(.leadingTrailingSpaces) } return .success(()) } @@ -92,6 +245,7 @@ nonisolated enum LocalFilenameValidationError: String, Error, Sendable, Codable, case disallowedCharacters case onlyRepeatingCharacters case leadingTrailingSpaces + case disallowedMimetype static let autoFixable: Set = [.disallowedPrefix, .disallowedCharacters, .leadingTrailingSpaces] diff --git a/CommonsFinder/Views/DraftViews/BaseDraftImageView.swift b/CommonsFinder/Views/DraftViews/BaseDraftImageView.swift new file mode 100644 index 0000000..17bf71c --- /dev/null +++ b/CommonsFinder/Views/DraftViews/BaseDraftImageView.swift @@ -0,0 +1,34 @@ +// +// BaseDraftImageView.swift +// CommonsFinder +// +// Created by Tom Brewe on 19.05.26. +// + +import NukeUI +import SwiftUI + +struct BaseDraftImageView: View { + let draft: MediaFileDraft + var body: some View { + LazyImage(request: draft.localFileRequestResizedGridThumb, transaction: .init(animation: .linear(duration: 0.3))) { state in + if let image = state.image { + image + .resizable() + .scaledToFill() + .clipped() + } else { + Image(.debugDraft) + .resizable() + .scaledToFill() + .clipped() + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipped() + } +} + +#Preview { + BaseDraftImageView(draft: .makeRandomDraft(id: "1")) +} diff --git a/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift b/CommonsFinder/Views/DraftViews/DraftListItem.swift similarity index 71% rename from CommonsFinder/Views/Reusable Views/DraftFileListItem.swift rename to CommonsFinder/Views/DraftViews/DraftListItem.swift index 313d46f..4a20607 100644 --- a/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift +++ b/CommonsFinder/Views/DraftViews/DraftListItem.swift @@ -1,5 +1,5 @@ // -// DraftFileListItem.swift +// DraftListItem.swift // CommonsFinder // // Created by Tom Brewe on 21.01.25. @@ -12,10 +12,10 @@ import NukeUI import SwiftUI import os.log -struct DraftFileListItem: View { +struct DraftListItem: View { let draft: MediaFileDraft - @Environment(Navigation.self) private var navigationModel + @Environment(Navigation.self) private var navigation @Environment(AccountModel.self) private var account @Environment(UploadManager.self) private var uploadManager @Environment(\.appDatabase) private var appDatabase @@ -27,7 +27,7 @@ struct DraftFileListItem: View { @State private var isShowingErrorSheet = false private func editDraft() { - navigationModel.editDrafts(drafts: [draft]) + navigation.editDraft(draft: draft) } private func showDeleteDialog() { @@ -40,9 +40,8 @@ struct DraftFileListItem: View { private func continueUpload() { isShowingErrorSheet = false - if let activeUser = account.activeUser, - let draft = try? appDatabase.updateDraft(id: draft.id, withPublishingError: nil) - { + if let activeUser = account.activeUser { + try? appDatabase.updateDraft(id: draft.id, withPublishingError: nil) uploadManager.upload(draft, username: activeUser.username) } } @@ -51,14 +50,36 @@ struct DraftFileListItem: View { var body: some View { lazy var publishingState = draft.publishingState - let canUpload = draft.uploadPossibleStatus == .uploadPossible && draft.publishingState == nil + let canUpload = draft.uploadPossibleStatus == .uploadPossible && account.activeUser != nil && draft.publishingState == nil let isPublishingCurrently = publishingState != nil && draft.publishingError == nil - Button(action: editDraft) { - imageView - .blur(radius: isPublishingCurrently ? 20 : 0) + + VStack { + Button(action: editDraft) { + imageView + .overlay(alignment: .bottomTrailing) { + ZStack { + if canUpload { + Button("Publish", systemImage: "arrowshape.up.fill", action: showUploadDialog) + } else if publishingState == nil { + Button("Edit", systemImage: "square.and.pencil", action: editDraft) + } + } + .glassButtonStyle() + .padding() + } + .blur(radius: isPublishingCurrently ? 20 : 0) + } + .frame(height: 200) + .buttonStyle(MediaCardButtonStyle()) + .overlay { + uploadProgressOverlay + } + + Text("Some info") } - .buttonStyle(MediaCardButtonStyle()) + .frame(width: 200) + .contentShape(.contextMenuPreview, .rect(cornerRadius: 18)) .contextMenu( menuItems: { if !isPublishingCurrently { @@ -77,40 +98,10 @@ struct DraftFileListItem: View { } }, preview: { - LazyImage(request: draft.localFileRequestResized) { phase in - if draft.isDebugDraft { - #if DEBUG - Image(.debugDraft) - #endif - } else if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Color.clear.frame( - width: Double(draft.width ?? 200), - height: Double(draft.height ?? 200) - ) - } - } - + imageView } ) - .overlay(alignment: .bottomTrailing) { - ZStack { - if canUpload { - Button("Publish", systemImage: "arrowshape.up.fill", action: showUploadDialog) - } else if publishingState == nil { - Button("Edit", systemImage: "square.and.pencil", action: editDraft) - } - } - .glassButtonStyle() - .padding() - } .disabled(isPublishingCurrently) - .overlay { - uploadProgressOverlay - } .geometryGroup() .confirmationDialog("Are you sure you want to delete the Draft?", isPresented: $isShowingDeleteDialog, titleVisibility: .visible) { Button("Delete", systemImage: "trash", role: .destructive) { @@ -149,6 +140,8 @@ struct DraftFileListItem: View { if draft.isDebugDraft { #if DEBUG Image(.debugDraft) + .resizable() + .aspectRatio(contentMode: .fill) #endif } else if let image = phase.image { image @@ -157,20 +150,14 @@ struct DraftFileListItem: View { } else { Color.clear - .frame( - width: Double(draft.width ?? 200), - height: Double(draft.height ?? 200) - ) - .overlay { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } - + .aspectRatio(contentMode: .fill) } } } .clipShape(.rect(cornerRadius: 16)) - .frame(width: 200, height: 200) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + + } @ViewBuilder @@ -185,13 +172,19 @@ struct DraftFileListItem: View { } else if let publishingState { switch publishingState { case .uploading: - ProgressView(value: publishingState.uploadProgress, total: 1) + Text("\(Int((publishingState.uploadProgress ?? 0) * 100))%") + .contentTransition(.numericText(countsDown: false)) + .font(.system(size: 40)) + .bold() + .foregroundStyle(.regularMaterial) + .shadow(radius: 10) + case .creatingWikidataClaims, .unstashingFile, .uploaded(filekey: _): ProgressView().progressViewStyle(.circular) case .published: Image(systemName: "checkmark.circle.fill") - .resizable() - .scaledToFit() + .aspectRatio(contentMode: .fit) + .font(.system(size: 120)) .padding() .foregroundStyle(.regularMaterial) .transition(.blurReplace.animation(.bouncy(extraBounce: 0.2))) @@ -227,3 +220,15 @@ struct DraftFileListItem: View { .glassButtonStyle() } } + + +#Preview(traits: .previewEnvironment) { + ScrollView(.horizontal) { + HStack { + DraftListItem(draft: .makeRandomDraft(id: "1")) + DraftListItem(draft: .makeRandomDraft(id: "2")) + DraftListItem(draft: .makeRandomDraft(id: "3")) + } + .scenePadding() + } +} diff --git a/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift b/CommonsFinder/Views/DraftViews/DraftSheetModifer.swift similarity index 73% rename from CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift rename to CommonsFinder/Views/DraftViews/DraftSheetModifer.swift index 73ec05a..9d80348 100644 --- a/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift +++ b/CommonsFinder/Views/DraftViews/DraftSheetModifer.swift @@ -1,5 +1,5 @@ // -// DraftSheetModifer.swift +// ImportFilesModifer.swift // CommonsFinder // // Created by Tom Brewe on 08.10.24. @@ -12,12 +12,10 @@ import PhotosUI import SwiftUI import os.log -struct DraftSheetModifer: ViewModifier { +struct ImportFilesModifer: ViewModifier { @Binding var importModel: FileImportModel? - @State private var draftedFileModels: [MediaFileDraftModel]? - - + @Environment(Navigation.self) private var navigation @Environment(\.appDatabase) private var appDatabase @Environment(\.dismiss) private var dismiss @@ -64,26 +62,12 @@ struct DraftSheetModifer: ViewModifier { func body(content: Content) -> some View { content - .sheet(item: $draftedFileModels, onDismiss: { importModel = nil }) { draftedFileModels in - - NavigationStack { - if draftedFileModels.count == 1, let draftedFileModel = draftedFileModels.first { - SingleImageDraftView(model: draftedFileModel) - } else if draftedFileModels.count > 1 { - Color.red.overlay { - Text("Multiple files") - } - } - - } - } .photosPicker( isPresented: isPhotosPickerPresented, selection: photosPickerSelection, - // NOTE: For now only allow 1 image until - // multi-upload is refined. - maxSelectionCount: 1, + maxSelectionCount: 250, matching: .any(of: [.images]), + // `.compatible` is what converts images to jpeg files preferredItemEncoding: .compatible, photoLibrary: .shared() ) @@ -114,18 +98,22 @@ struct DraftSheetModifer: ViewModifier { } .onChange(of: importModel?.importStatus) { guard let importModel, importModel.importStatus == .finished else { return } - let fileCount = importModel.editedDrafts.count - if fileCount == 1, let newDraftModel = importModel.editedDrafts.values.first { - draftedFileModels = [newDraftModel] + let fileCount = importModel.importedDrafts.count + if fileCount == 1, let newDraft = importModel.importedDrafts.values.first { + navigation.editDraft(draft: newDraft) } else if fileCount > 1 { - draftedFileModels = Array(importModel.editedDrafts.values) + let info = MultiDraftInfo( + multiDraft: .init(newDraftOptions: importModel.newDraftOptions), + drafts: importModel.importedDrafts.values.elements + ) + navigation.editMultipleDrafts(multiDraftInfo: info) } } } } -extension [MediaFileDraftModel]: @retroactive Identifiable { +extension [SingleDraftModel]: @retroactive Identifiable { public var id: String { self.reduce("") { partialResult, next in partialResult + next.id diff --git a/CommonsFinder/Views/DraftViews/FileLocationMapView.swift b/CommonsFinder/Views/DraftViews/FileLocationMapView.swift new file mode 100644 index 0000000..101794d --- /dev/null +++ b/CommonsFinder/Views/DraftViews/FileLocationMapView.swift @@ -0,0 +1,62 @@ +// +// FileLocationMapView.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import CoreLocation +import GEOSwift +import GEOSwiftMapKit +import MapKit +import SwiftUI + +struct FileLocationMapView: View { + let coordinates: [CLLocationCoordinate2D] + var label: String? + + @State private var markerLabel: String? + + var body: some View { + + if let paddedRegion = try? MKCoordinateRegion.init(containing: MultiPoint(points: coordinates.map(Point.init)), paddingFactor: 0.2, minPadding: 500) { + Map(initialPosition: .region(paddedRegion)) { + if coordinates.count == 1, let coordinate = coordinates.first { + Marker(label ?? "", coordinate: coordinate) + } else { + ForEach(coordinates, id: \.hashValue) { coordinate in + Annotation(coordinate: coordinate) { + Color.red.opacity(0.6) + .frame(width: 10, height: 10) + .clipShape(.circle) + .overlay { + Circle() + .stroke(lineWidth: 2) + .foregroundStyle(.white.opacity(0.6)) + } + } label: { + } + + } + } + + + } + .mapControlVisibility(.hidden) + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .allowsHitTesting(false) + .frame(height: 200) + .clipShape(.rect(cornerRadius: 15)) + } + + } +} + + +#Preview(traits: .previewEnvironment) { + FileLocationMapView(coordinates: [.init(latitude: 0, longitude: 0)]) +} + +#Preview(traits: .previewEnvironment) { + FileLocationMapView(coordinates: [.init(latitude: 0, longitude: 0.1), .init(latitude: 0.1, longitude: 0), .init(latitude: 0.2, longitude: 0.325), .init(latitude: -5, longitude: 0.075)]) +} diff --git a/CommonsFinder/Views/DraftViews/FilenameErrorButton.swift b/CommonsFinder/Views/DraftViews/FilenameErrorButton.swift new file mode 100644 index 0000000..bf802bf --- /dev/null +++ b/CommonsFinder/Views/DraftViews/FilenameErrorButton.swift @@ -0,0 +1,70 @@ +// +// FilenameErrorButton.swift +// CommonsFinder +// +// Created by Tom Brewe on 25.03.26. +// + + +import SwiftUI + +struct FilenameErrorButton: View { + let nameValidationResult: NameValidationResult + let fileNameType: FileNameType + + let onDismiss: () -> Void + let onSanitize: () -> Void + + @State private var isFilenameAlertPresented = false + + var body: some View { + Button { + switch nameValidationResult { + case .success(_): + // do nothing, alternatively, tell user, the full filename including name ending and + // that it was checked with the backend? + break + case .failure(_): + isFilenameAlertPresented = true + } + + } label: { + switch nameValidationResult { + case .failure(_): + Image(systemName: "exclamationmark.circle") + .foregroundStyle(.red) + case .success(_): + Image(systemName: "checkmark.circle") + .foregroundStyle(.green) + } + } + .alert( + nameValidationResult.alertTitle ?? "", + isPresented: $isFilenameAlertPresented, + presenting: nameValidationResult.error, + actions: { error in + if case .invalid(let localInvalidationError) = error, + let localInvalidationError, localInvalidationError.canBeAutoFixed == true, + fileNameType == .custom + { + Button("sanitize", action: onSanitize) + } + Button("Ok", action: onDismiss) + }, + message: { error in + let failureReason = nameValidationResult.error?.failureReason + let recoverySuggestion = nameValidationResult.error?.recoverySuggestion + + let isFailureReasonIdenticalToTitle = failureReason == nameValidationResult.alertTitle + if let failureReason, let recoverySuggestion, !isFailureReasonIdenticalToTitle { + Text(failureReason + "\n\n\(recoverySuggestion)") + } else if let recoverySuggestion { + Text(recoverySuggestion) + } + } + ) + .imageScale(.large) + .frame(width: 10) + } + +} diff --git a/CommonsFinder/Views/FileCreateView/FilenameTip.swift b/CommonsFinder/Views/DraftViews/FilenameTip.swift similarity index 100% rename from CommonsFinder/Views/FileCreateView/FilenameTip.swift rename to CommonsFinder/Views/DraftViews/FilenameTip.swift diff --git a/CommonsFinder/Views/FileCreateView/LicensePicker.swift b/CommonsFinder/Views/DraftViews/LicensePicker.swift similarity index 100% rename from CommonsFinder/Views/FileCreateView/LicensePicker.swift rename to CommonsFinder/Views/DraftViews/LicensePicker.swift diff --git a/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift b/CommonsFinder/Views/DraftViews/Model/FileImportModel.swift similarity index 67% rename from CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift rename to CommonsFinder/Views/DraftViews/Model/FileImportModel.swift index cbbbd39..8be0f50 100644 --- a/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift +++ b/CommonsFinder/Views/DraftViews/Model/FileImportModel.swift @@ -17,7 +17,6 @@ enum DraftError: Error { case filenameExistsAlready(name: String) } - /// DraftModel models a drafting session where the user can add & remove files and also edit their metadata @Observable class FileImportModel: Identifiable { private var photoImportTask: Task? @@ -35,33 +34,18 @@ enum DraftError: Error { } var importStatus: ImportStatus? - /// The currently centered file in the scrollView that is being edited - var selectedID: MediaFileDraftModel.ID? - var photosPickerSelection: [PhotosPickerItem] = [] { didSet { handleNewPhotoItemSelection(oldValue: oldValue, currentValue: photosPickerSelection) } } - var editedDrafts: OrderedDictionary - var selectedDraft: MediaFileDraftModel? { - if let selectedID { - editedDrafts[selectedID] - } else { - nil - } - } - - var fileCount: Int { - photosPickerSelection.count + editedDrafts.count - } - + var importedDrafts: OrderedDictionary // var draftsExistInDB: Bool = false init(newDraftOptions: NewDraftOptions?) { - self.id = .init() + id = .init() switch newDraftOptions?.source { case .mediaLibrary: isPhotosPickerPresented = true @@ -71,33 +55,8 @@ enum DraftError: Error { } self.newDraftOptions = newDraftOptions - self.importStatus = nil - - editedDrafts = .init() - } - - convenience init(existingDrafts: [MediaFileDraft], newDraftOptions: NewDraftOptions? = nil) { - self.init(newDraftOptions: newDraftOptions) - importStatus = .finished - - for existingDraft in existingDrafts { - let model = MediaFileDraftModel(existingDraft: existingDraft) - editedDrafts[model.id] = model - } - if !existingDrafts.isEmpty { - // Check if drafts are known to the DB - // TODO: maybe init from ID in the first place? - // do { - // draftsExistInDB = try appDatabase.reader.read { - // try existingDrafts.count - // == MediaFileDraft - // .filter(ids: existingDrafts.map(\.id)) - // .fetchCount($0) - // } - // } catch { - // logger.error("Failed to check if drafts exist in DB \(error)") - // } - } + importStatus = nil + importedDrafts = .init() } func handleNewPhotoItemSelection(oldValue: [PhotosPickerItem], currentValue: [PhotosPickerItem]) { @@ -111,7 +70,7 @@ enum DraftError: Error { // remove all previously imported items that are not in the selection anymore removedItemIDs.forEach { id in - editedDrafts.removeValue(forKey: id) + importedDrafts.removeValue(forKey: id) } photoImportTask = Task { @@ -128,8 +87,8 @@ enum DraftError: Error { do { let fileItem = try await FileItem.init(photoPickerItem: photoItem) try Task.checkCancellation() - let draft = try MediaFileDraftModel(fileItem: fileItem, newDraftOptions: newDraftOptions) - editedDrafts[draft.id] = draft + let draft = try MediaFileDraft(fileItem, newDraftOptions: newDraftOptions) + importedDrafts[draft.id] = draft } catch { logger.error("Failed to create fileItem of photo \(photoItem.itemIdentifier ?? ""): \(error)") } @@ -147,8 +106,8 @@ enum DraftError: Error { for url in fileURLs { do { let fileItem = try await loadFileItem(url: url) - let newDraft = try MediaFileDraftModel(fileItem: fileItem, newDraftOptions: newDraftOptions) - editedDrafts[newDraft.id] = newDraft + let draft = try MediaFileDraft(fileItem, newDraftOptions: newDraftOptions) + importedDrafts[draft.id] = draft } catch { logger.error("Failed to import file. \(error)") } @@ -183,9 +142,8 @@ enum DraftError: Error { let fileItem = try FileItem.init(uiImage: uiImage, metadata: metadata, location: cameraLocation) - let newDraft = try MediaFileDraftModel(fileItem: fileItem, newDraftOptions: newDraftOptions) - - editedDrafts[newDraft.id] = newDraft + let draft = try MediaFileDraft(fileItem, newDraftOptions: newDraftOptions) + importedDrafts[draft.id] = draft importStatus = .finished } diff --git a/CommonsFinder/Views/FileCreateView/Model/FileItem.swift b/CommonsFinder/Views/DraftViews/Model/FileItem.swift similarity index 100% rename from CommonsFinder/Views/FileCreateView/Model/FileItem.swift rename to CommonsFinder/Views/DraftViews/Model/FileItem.swift diff --git a/CommonsFinder/Views/DraftViews/Model/MultiDraftModel.swift b/CommonsFinder/Views/DraftViews/Model/MultiDraftModel.swift new file mode 100644 index 0000000..ba8c8cd --- /dev/null +++ b/CommonsFinder/Views/DraftViews/Model/MultiDraftModel.swift @@ -0,0 +1,98 @@ +// +// MultiDraftModel.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + + +import CommonsAPI +import CoreLocation +import Foundation +import GEOSwift +import GEOSwiftMapKit +import Nuke +import UniformTypeIdentifiers +import os.log + +// TODO: perhaps consolidate as view state directly, because a dedicated @observable model doesn't provide a benefit with the current setup, same for single draft model (!) +@Observable final class MultiDraftModel: @preconcurrency Identifiable { + typealias ID = String + var id: ID + var info: MultiDraftInfo + + var suggestedFilenames: [FileNameTypeTuple] = [] + var nameValidationResult: NameValidationResult? + + var choosenCoordinates: [CLLocationCoordinate2D] { + return switch info.multiDraft.locationHandling { + case .userDefinedLocation(latitude: let lat, longitude: let lon, _): + [.init(latitude: lat, longitude: lon)] + case .exifLocation: + exifData.values.compactMap(\.coordinate) + case .noLocation: + [] + case .none: + [] + } + } + + var centroidCoordinate: CLLocationCoordinate2D? { + let points: GEOSwift.MultiPoint = .init(points: choosenCoordinates.compactMap(Point.init)) + if let centroid = try? points.centroid() { + return .init(centroid) + } else { + return nil + } + } + + var minimumBoundingCircleRadiusOfCoordinates: Double? { + let points: GEOSwift.MultiPoint = .init(points: choosenCoordinates.compactMap(Point.init)) + return try? points.minimumBoundingCircle().radius + } + + + func validateFilenameImpl() async throws { + nameValidationResult = nil + info.multiDraft.uploadPossibleStatus = nil + try await Task.sleep(for: .milliseconds(500)) + + // FIXME: actually validate + + + let finalFilenames: [String] = + try FilenameUtils + .generateMultiDraftFinalFilenames(multiDraftInfo: info) + .map(\.value) + + + nameValidationResult = try await DraftValidation.validateBatchFilenames(filenamesWithSuffix: finalFilenames) + info.multiDraft.uploadPossibleStatus = DraftValidation.canUploadDraft(info.multiDraft, nameValidationResult: nameValidationResult) + } + + func saveChanges(appDatabase: AppDatabase) { + do { + info = try appDatabase.upsertAndFetch(info) + } catch { + logger.error("Failed to save all drafts \(error)") + } + } + + @ObservationIgnored + lazy var exifData: [MediaFileDraft.ID: ExifData] = { + var result: [MediaFileDraft.ID: ExifData] = [:] + for draft in info.drafts { + if let exifData = draft.loadExifData() { + result[draft.id] = exifData + } + } + return result + }() + + + init(_ info: MultiDraftInfo) { + id = UUID().uuidString + self.info = info + nameValidationResult = nil + } +} diff --git a/CommonsFinder/Views/DraftViews/Model/NameValidationResult.swift b/CommonsFinder/Views/DraftViews/Model/NameValidationResult.swift new file mode 100644 index 0000000..ec55dc1 --- /dev/null +++ b/CommonsFinder/Views/DraftViews/Model/NameValidationResult.swift @@ -0,0 +1,31 @@ +// +// NameValidationResult.swift +// CommonsFinder +// +// Created by Tom Brewe on 25.03.26. +// + + +typealias NameValidationResult = Result + +extension NameValidationResult { + var error: NameValidationError? { + switch self { + case .success: return nil + case .failure(let error): return error + } + } + + var alertTitle: String? { + if let error { + switch error { + case .invalid(_): + error.failureReason + default: + error.errorDescription + } + } else { + nil + } + } +} diff --git a/CommonsFinder/Views/DraftViews/Model/SingleDraftModel.swift b/CommonsFinder/Views/DraftViews/Model/SingleDraftModel.swift new file mode 100644 index 0000000..a504f74 --- /dev/null +++ b/CommonsFinder/Views/DraftViews/Model/SingleDraftModel.swift @@ -0,0 +1,63 @@ +// +// SingleDraftModel.swift +// CommonsFinder +// +// Created by Tom Brewe on 13.10.24. +// + +import CommonsAPI +import CoreLocation +import Foundation +import Nuke +import UniformTypeIdentifiers +import os.log + +// TODO: perhaps consolidate as view state directly +@Observable final class SingleDraftModel: @preconcurrency Identifiable { + typealias ID = String + var id: ID + var draft: MediaFileDraft + + var suggestedFilenames: [FileNameTypeTuple] = [] + var nameValidationResult: NameValidationResult? + + @ObservationIgnored + lazy var exifData: ExifData? = { + draft.loadExifData() + }() + + /// Use an already fully initialized draft + init(existingDraft: MediaFileDraft) { + id = existingDraft.id + draft = existingDraft + } + + var choosenCoordinate: CLLocationCoordinate2D? { + return switch draft.locationHandling { + case .userDefinedLocation(latitude: let lat, longitude: let lon, _): + .init(latitude: lat, longitude: lon) + case .exifLocation: + exifData?.coordinate + case .noLocation: + nil + case .none: + nil + } + } + + func validateFilenameImpl() async throws { + nameValidationResult = nil + draft.uploadPossibleStatus = nil + try await Task.sleep(for: .milliseconds(500)) + nameValidationResult = await DraftValidation.validateFilename(name: draft.name, mimeType: draft.mimeType) + draft.uploadPossibleStatus = DraftValidation.canUploadDraft(draft, nameValidationResult: nameValidationResult) + } + + func saveChanges(appDatabase: AppDatabase) { + do { + draft = try appDatabase.upsert(draft) + } catch { + logger.error("Failed to save all drafts \(error)") + } + } +} diff --git a/CommonsFinder/Views/DraftViews/MultiDraftListItem.swift b/CommonsFinder/Views/DraftViews/MultiDraftListItem.swift new file mode 100644 index 0000000..dec0073 --- /dev/null +++ b/CommonsFinder/Views/DraftViews/MultiDraftListItem.swift @@ -0,0 +1,349 @@ +// +// MultiDraftListItem.swift +// CommonsFinder +// +// Created by Tom Brewe on 07.05.26. +// + + +import NukeUI +import SwiftUI +import os.log + +struct MultiDraftListItem: View { + let multiDraftInfo: MultiDraftInfo + + @Environment(Navigation.self) private var navigation + @Environment(AccountModel.self) private var account + @Environment(UploadManager.self) private var uploadManager + @Environment(\.appDatabase) private var appDatabase + @Namespace private var navigationNamespace + @Environment(\.locale) private var locale + + @State private var isShowingDeleteDialog = false + @State private var isShowingUploadDialog = false + @State private var isShowingErrorSheet = false + + + private var rowCount: Int { + (multiDraftInfo.drafts.count == 2) ? 1 : 2 + } + private var rows: [GridItem] { + return (0..= 1 { + var errorString = AttributedString(" · \(multiDraftInfo.publishingErrorUploadCount) failed") + errorString.foregroundColor = .red + attributedString.append(errorString) + } + + return attributedString + } else { + var attributedString = AttributedString("\(publishingState.completedCount) of \(publishingState.totalCount)") + + attributedString.foregroundColor = Color.publishingInProgressAccent + + + if multiDraftInfo.publishingErrorUploadCount >= 1 { + var errorString = AttributedString(" · \(multiDraftInfo.publishingErrorUploadCount) failed") + errorString.foregroundColor = .red + attributedString.append(errorString) + } + + return attributedString + } + + + } + + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + imageGridButton + .overlay(alignment: .bottomTrailing) { + ZStack { + if canUpload { + Button("Publish", systemImage: "arrowshape.up.fill", action: showUploadDialog) + } else if publishingState == nil { + Button("Edit", systemImage: "square.and.pencil", action: editDraft) + } + } + .glassButtonStyle() + .padding() + } + .overlay { + if let publishingState { + uploadProgressOverlay(publishingState: publishingState) + } + } + info + } + .contentShape(.contextMenuPreview, .rect(cornerRadius: 18)) + .frame(width: 200) + .geometryGroup() + .contextMenu( + menuItems: { + if publishingState == nil { + if canUpload { + Button("Publish", systemImage: "arrowshape.up", action: showUploadDialog) + } + // if draft.publishingState != .creatingWikidataClaims { + Button("Edit", systemImage: "pencil", action: editDraft) + // } + + Divider() + + Button("Delete", systemImage: "trash", role: .destructive, action: showDeleteDialog) + } else { + // TODO: show more upload info? + } + }, + preview: { + VStack { + imageGridButton + info + } + .padding() + } + ) + .confirmationDialog("Are you sure you want to delete the Draft?", isPresented: $isShowingDeleteDialog, titleVisibility: .visible) { + Button("Delete", systemImage: "trash", role: .destructive) { + do { + try appDatabase.delete(multiDraftInfo) + } catch { + logger.error("Failed to delete drafts \(error)") + } + } + + Button("Cancel", role: .cancel) { + isShowingDeleteDialog = false + } + } + .confirmationDialog("Start upload to Wikimedia Commons now?", isPresented: $isShowingUploadDialog, titleVisibility: .visible) { + Button("Upload", systemImage: "square.and.arrow.up") { + if let activeUser = account.activeUser { + uploadManager.upload(multiDraftInfo, username: activeUser.username) + } + } + + Button("Cancel", role: .cancel) { + isShowingDeleteDialog = false + } + } + } + + + @ViewBuilder + private var imageGridButton: some View { + Button { + navigation.editMultipleDrafts(multiDraftInfo: multiDraftInfo) + } label: { + imageGrid + .frame(width: 200, height: 200) + .background(Color.cardBackground) + .blur(radius: (publishingState != nil) ? 20 : 0) + } + .clipped() + .buttonStyle(MediaCardButtonStyle()) + } + + private var info: some View { + VStack(alignment: .leading) { + let name = multiDraftInfo.multiDraft.name + if !name.isEmpty { + Text(name) + .lineLimit(2, reservesSpace: false) + .foregroundStyle(.primary) + .bold() + } else { + Text("untitled Draft") + .italic() + .foregroundStyle(.secondary) + } + + let byteStyle = ByteCountFormatStyle(style: .file, allowedUnits: [.kb, .mb, .gb, .tb]) + + + if let statusLine { + Text(statusLine) + } else { + let totalBytesFormatted = byteStyle.format(multiDraftInfo.combinedFileSizeInByte) + + Text("\(multiDraftInfo.drafts.count) files · \(totalBytesFormatted)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.leading) + .padding(.horizontal, 5) + } + + @ViewBuilder + private var imageGrid: some View { + let draftCount = multiDraftInfo.drafts.count + let spacing = 3.0 + + Group { + switch draftCount { + case 2: + HStack(spacing: spacing) { + ForEach(multiDraftInfo.drafts[0...1]) { draft in + BaseDraftImageView(draft: draft) + } + } + + case 3: + HStack(spacing: spacing) { + BaseDraftImageView(draft: multiDraftInfo.drafts[0]) + .containerRelativeFrame(.horizontal, count: 2, spacing: 5) + + VStack(spacing: spacing) { + ForEach(multiDraftInfo.drafts[1...2]) { draft in + BaseDraftImageView(draft: draft) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .containerRelativeFrame(.horizontal, count: 2, spacing: 5) + } + + default: + Grid(horizontalSpacing: spacing, verticalSpacing: spacing) { + GridRow { + ForEach(multiDraftInfo.drafts[0...1]) { draft in + BaseDraftImageView(draft: draft) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + GridRow { + ForEach(multiDraftInfo.drafts[2...3]) { draft in + BaseDraftImageView(draft: draft) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + } + } + } + + @ViewBuilder + private func uploadProgressOverlay(publishingState: MultiDraft.PublishingState) -> some View { + + ZStack { + if publishingState.isFinished, + multiDraftInfo.publishingErrorUploadCount >= 1 + { + errorButton + } else if !publishingState.isFinished { + Text("\(Int(publishingState.overallProgress * 100))%") + .contentTransition(.numericText(countsDown: false)) + .font(.system(size: 40)) + .bold() + .foregroundStyle(.regularMaterial) + .shadow(radius: 10) + } else if publishingState.isFinished, multiDraftInfo.publishingErrorUploadCount == 0 { + Image(systemName: "checkmark.circle.fill") + .aspectRatio(contentMode: .fit) + .font(.system(size: 120)) + .padding() + .foregroundStyle(.regularMaterial) + .transition(.blurReplace.animation(.bouncy(extraBounce: 0.2))) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .padding() + .clipShape(.rect(cornerRadius: 16)) + .animation(.default, value: publishingState) + // .publishingErrorDetailsSheet( + // draft.publishingState, + // draft.publishingError, + // isPresented: $isShowingErrorSheet, + // onEditDraft: editDraft, + // onDeleteDraft: showDeleteDialog, + // onContinueUpload: continueUpload + // ) + } + + @ViewBuilder + private var errorButton: some View { + Button { + isShowingErrorSheet = true + } label: { + Label("show errors", systemImage: "exclamationmark.triangle.fill") + .symbolRenderingMode(.hierarchical) + } + .transition(.blurReplace.animation(.bouncy)) + .foregroundStyle(.primary) + .glassButtonStyle() + } +} + +#Preview(traits: .previewEnvironment) { + ScrollView(.horizontal) { + LazyVGrid(columns: [.init(), .init()], alignment: .center, spacing: 5) { + + Group { + MultiDraftListItem(multiDraftInfo: .makeRandom(id: 1, imageCount: 2)) + + MultiDraftListItem(multiDraftInfo: .makeRandom(id: 2, imageCount: 3)) + + MultiDraftListItem(multiDraftInfo: .makeRandom(id: 3, imageCount: 4)) + + MultiDraftListItem(multiDraftInfo: .makeRandom(id: 4, imageCount: 51)) + } + + + } + .padding() + + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + + +} diff --git a/CommonsFinder/Views/DraftViews/MultiDraftOverviewList.swift b/CommonsFinder/Views/DraftViews/MultiDraftOverviewList.swift new file mode 100644 index 0000000..8168c19 --- /dev/null +++ b/CommonsFinder/Views/DraftViews/MultiDraftOverviewList.swift @@ -0,0 +1,58 @@ +// +// MultiDraftOverviewList.swift +// CommonsFinder +// +// Created by Tom Brewe on 19.05.26. +// + +import NukeUI +import SwiftUI + +struct MultiDraftOverviewList: View { + @Bindable var multiDraftModel: MultiDraftModel + + + var body: some View { + let enumeratedDescs = Array(multiDraftModel.info.multiDraft.captionWithDesc.enumerated()) + + List(multiDraftModel.info.drafts) { draft in + + VStack(alignment: .leading) { + BaseDraftImageView(draft: draft) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipShape(.rect(cornerRadius: 16)) + + Text(draft.name) + .italic() + + if draft.captionWithDesc.isEmpty { + ForEach(enumeratedDescs, id: \.element.languageCode) { item in + + let caption = item.element.caption + if !caption.isEmpty { + Text(caption) + } + + let fullDescription = item.element.fullDescription + if !fullDescription.isEmpty { + Text(fullDescription) + } + + } + .foregroundStyle(.secondary) + } else { + Text("customized Text") + .bold() + } + } + + .frame(height: 300) + } + + } + +} + +#Preview { + MultiDraftOverviewList(multiDraftModel: .init(.makeRandom(id: 1, imageCount: 5))) +} diff --git a/CommonsFinder/Views/DraftViews/MultiDraftSheetModifier.swift b/CommonsFinder/Views/DraftViews/MultiDraftSheetModifier.swift new file mode 100644 index 0000000..fd9feee --- /dev/null +++ b/CommonsFinder/Views/DraftViews/MultiDraftSheetModifier.swift @@ -0,0 +1,21 @@ +// +// MultiDraftSheetModifier.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import SwiftUI + +struct MultiDraftSheetModifier: ViewModifier { + @Binding var multiDraftModel: MultiDraftModel? + + func body(content: Content) -> some View { + content + .sheet(item: $multiDraftModel) { model in + NavigationStack { + MultiDraftView(model: model) + } + } + } +} diff --git a/CommonsFinder/Views/DraftViews/MultiDraftView.swift b/CommonsFinder/Views/DraftViews/MultiDraftView.swift new file mode 100644 index 0000000..09ffc7a --- /dev/null +++ b/CommonsFinder/Views/DraftViews/MultiDraftView.swift @@ -0,0 +1,696 @@ +// +// MultiDraftView.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import CommonsAPI +import FrameUp +@preconcurrency import MapKit +import NukeUI +import OrderedCollections +import SwiftUI +import TipKit +import UniformTypeIdentifiers +import os.log + +struct MultiDraftView: View { + @Bindable var model: MultiDraftModel + + @Environment(UploadManager.self) private var uploadManager + @Environment(AccountModel.self) private var account + @Environment(\.appDatabase) private var appDatabase + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Environment(\.locale) private var locale + @Environment(FileAnalysis.self) private var fileAnalysis + @FocusState private var focus: FocusElement? + + @State private var filenameSelection: TextSelection? + @State private var isLicensePickerShowing = false + @State private var isTimezonePickerShowing = false + @State private var locationLabel: String? + + @State private var isZoomableImageViewerPresented = false + @State private var zoomableImageReference: ZoomableImageReference? + + @State private var isShowingDeleteDialog = false + @State private var isShowingUploadDialog = false + @State private var isShowingCloseConfirmationDialog = false + @State private var isShowingUploadDisabledAlert = false + @State private var isShowingTagsPicker = false + @State private var isShowingCategoryPicker = false + @State private var isShowingFileListSheet = false + + private var draftExistsInDB: Bool { + model.info.multiDraft.id != nil + } + + private enum FocusElement: Hashable { + case caption + case description + case tags + case license + case filename + } + + private var analysisInput: FileAnalysis.Input? { + return if let centroidCoordinate = model.centroidCoordinate { + .fileLocation(centroidCoordinate, horizontalError: model.minimumBoundingCircleRadiusOfCoordinates, bearing: nil) + } else { + nil + } + } + + var body: some View { + Form { + imageCarouselView + captionAndDescriptionSection + tagsSection + locationSection + attributionSection + // dateTimeSection + filenameSection + + Color.clear + .frame(height: 50) + .listRowBackground(Color.clear) + } + .navigationTitle("Draft (\(model.info.drafts.count) files)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(!draftExistsInDB) + // NOTE: Not using a regular sheet here: .sheet + ScrollView + ForEach Buttons causes accidental button taps when scrolling (SwiftUI bug?) + // so for now until this behaviour is fixed by Apple + // this is a fullScreenCover (but TODO: consider using a push navigation here) + .fullScreenCover(isPresented: $isShowingTagsPicker) { + // FIXME: support multi-draft centroid + + TagPicker( + initialTags: model.info.multiDraft.tags, + analysisInput: analysisInput, + onEditedTags: { model.info.multiDraft.tags = $0 }, + ) + + } + // .sheet(isPresented: $isTimezonePickerShowing) { + // TimezonePicker(selectedTimezone: $model.multiDraft.timezone) + // .presentationDetents([.medium, .large]) + // } + .sheet(isPresented: $isShowingFileListSheet) { + MultiDraftOverviewList(multiDraftModel: model) + } + .onAppear { + if model.info.multiDraft.captionWithDesc.isEmpty { + focus = .caption + } + } + .onChange(of: model.info) { + if focus != .filename { + generateFilename() + } + + model.info.multiDraft.uploadPossibleStatus = DraftValidation.canUploadDraft( + model.info.multiDraft, + nameValidationResult: model.nameValidationResult + ) + } + .onChange(of: model.info.multiDraft.selectedFilenameType) { oldValue, newValue in + filenameSelection = .none + if newValue != .custom { + generateFilename() + } + } + // .onDisappear { + // if draftExistsInDB, model.multiDraft.publishingState == nil { + // saveChanges() + // } + // } + .task(id: model.info.multiDraft.name) { + do { + try await model.validateFilenameImpl() + } catch { + logger.error("Failed to validate name \(error)") + } + } + .task(id: analysisInput) { + if let analysisInput { + fileAnalysis.startAnalyzingIfNeeded(analysisInput) + } + } + // .task(id: model.choosenCoordinate) { + // locationLabel = nil + // guard let coordinate = model.choosenCoordinate else { return } + // do { + // locationLabel = try await coordinate.generateHumanReadableString() + // } catch { + // logger.error("failed generateHumanReadableString \(error)") + // } + // } + } + + + private func generateFilename() { + // TODO: move to model + Task { + let generatedFilename = + await model.info.multiDraft.selectedFilenameType.generateFilename( + // FIXME: coordinate? + coordinate: nil, + date: model.info.drafts.first?.inceptionDate, + desc: model.info.multiDraft.captionWithDesc, + locale: locale, + tags: model.info.multiDraft.tags + ) ?? model.info.multiDraft.name + + model.info.multiDraft.name = generatedFilename + } + } + + private func saveChangesAndDismiss() { + model.saveChanges(appDatabase: appDatabase) + dismiss() + } + + private func deleteDraftAndDismiss() { + do { + try appDatabase.delete(model.info) + dismiss() + } catch { + logger.error("Failed to delete drafts \(error)") + } + } + @ViewBuilder + private var captionAndDescriptionSection: some View { + Section("Description") { + let enumeratedDescs = Array(model.info.multiDraft.captionWithDesc.enumerated()) + let disabledLanguages = model.info.multiDraft.captionWithDesc.map(\.languageCode) + + List { + ForEach(enumeratedDescs, id: \.element.languageCode) { (idx, desc) in + let languageCode = desc.languageCode + + VStack(alignment: .leading) { + Menu(WikimediaLanguage(code: languageCode).localizedDescription) { + Text("Select Language") + Divider() + LanguageButtons(disabledLanguages: disabledLanguages) { selectedLanguage in + changeLanguageForCaptionAndDesc(old: languageCode, new: selectedLanguage.code) + } + Divider() + Button("Delete", role: .destructive) { + model.info.multiDraft.captionWithDesc.remove(at: idx) + } + + } + + TextField( + "caption", + text: $model.info.multiDraft.captionWithDesc[languageCode, .caption], + axis: .vertical + ) + .bold() + .focused($focus, equals: .caption) + .submitLabel(.next) + .onChange(of: model.info.multiDraft.captionWithDesc[languageCode, .caption]) { oldValue, newValue in + if newValue.count > 250 { + model.info.multiDraft.captionWithDesc[languageCode, .caption] = String(model.info.multiDraft.captionWithDesc[languageCode, .caption].prefix(250)) + } + } + .safeAreaInset(edge: .bottom) { + let captionLength = model.info.multiDraft.captionWithDesc[languageCode, .caption].count + if captionLength > 225 { + HStack { + Text("\(captionLength)/250 characters") + .font(.caption) + .foregroundStyle(captionLength == 250 ? Color.red : .secondary) + Spacer(minLength: 0) + } + } + } + + .onSubmit { + focus = .description + } + + TextField( + "detailed description (optional)", + text: $model.info.multiDraft.captionWithDesc[languageCode, .description], + axis: .vertical + ) + .focused($focus, equals: .description) + .submitLabel(.next) + .onSubmit { + focus = .tags + } + } + + } + .onDelete { set in + model.info.multiDraft.captionWithDesc.remove(atOffsets: set) + } + + Menu("Add", systemImage: "plus") { + Text("Choose language") + LanguageButtons(disabledLanguages: disabledLanguages, onSelect: { addLanguage(code: $0.code) }) + } + } + + + } + } + + private func addLanguage(code: LanguageCode) { + guard !model.info.multiDraft.captionWithDesc.contains(where: { $0.languageCode == code }) else { + assertionFailure("We expect the language code to not exist yet") + return + } + + withAnimation { + model.info.multiDraft.captionWithDesc.append(.init(languageCode: code)) + } + } + + private func changeLanguageForCaptionAndDesc(old: LanguageCode, new: LanguageCode) { + // dont change language if same, or if the new language already exists + // this is an assertion failure, as these actions should be disabled in the UI above. + guard old != new, + model.info.multiDraft.captionWithDesc.first(where: { $0.languageCode == new }) == nil + else { + assertionFailure() + return + } + + guard let idx = model.info.multiDraft.captionWithDesc.firstIndex(where: { $0.languageCode == old }) else { + assertionFailure("We expect the given old language code to both have an existing caption and desc in the draft") + return + } + + model.info.multiDraft.captionWithDesc[idx].languageCode = new + } + + + private var filenameSection: some View { + Section { + HStack { + TextField("Filename", text: $model.info.multiDraft.name, selection: $filenameSelection, axis: .vertical) + .textInputAutocapitalization(.sentences) + .focused($focus, equals: .filename) + .tint(.primary) + .padding(.trailing) + Spacer(minLength: 0) + + if let nameValidationResult = model.nameValidationResult { + FilenameErrorButton( + nameValidationResult: nameValidationResult, + fileNameType: model.info.multiDraft.selectedFilenameType, + onDismiss: { + let endIdx = model.info.multiDraft.name.endIndex + focus = .filename + filenameSelection = .init(range: endIdx.. token placeholder) + // for UI + // so the date is filled automatically? + date: model.info.drafts.first?.inceptionDate, + desc: model.info.multiDraft.captionWithDesc, + locale: Locale.current, + tags: model.info.multiDraft.tags + ) + + if let generatedFilename { + generatedSuggestions.append(.init(name: generatedFilename, type: type)) + } + + } + + model.suggestedFilenames = generatedSuggestions + + guard !model.info.multiDraft.name.isEmpty else { return } + + let matchingAutomatic = generatedSuggestions.first(where: { suggestion in + model.info.multiDraft.name == suggestion.name + }) + + if let matchingAutomatic { + model.info.multiDraft.selectedFilenameType = matchingAutomatic.type + } else { + model.info.multiDraft.selectedFilenameType = .custom + } + } + + } + + + private var tagsSection: some View { + Section { + let tags: [TagItem] = model.info.multiDraft.tags + + if !tags.isEmpty { + + HFlowLayout(alignment: .leading) { + ForEach(tags) { tag in + Button { + isShowingTagsPicker = true + } label: { + TagLabel(tag: tag) + } + .id(tag.id) + } + .buttonStyle(.plain) + } + .animation(.default, value: model.info.multiDraft.tags) + + + } + + Button( + model.info.multiDraft.tags.isEmpty ? "Add" : "Edit", + systemImage: model.info.multiDraft.tags.isEmpty ? "plus" : "pencil" + ) { + isShowingTagsPicker = true + } + .focused($focus, equals: .tags) + } header: { + Label("Tags", systemImage: "tag") + } footer: { + Text("Add **categories** and define what the image **depicts**. This makes your image discoverable and useful.") + } + } + + @ViewBuilder + private var locationSection: some View { + Section { + VStack(alignment: .leading) { + Toggle("Locations", systemImage: model.info.multiDraft.locationEnabled ? "location" : "location.slash", isOn: $model.info.multiDraft.locationEnabled) + .animation(.default) { + $0.contentTransition(.symbolEffect) + } + if model.info.multiDraft.locationEnabled == false { + Text("Location metadata will be erased from all \(model.info.drafts.count) files before uploading.") + .font(.caption) + } else if !model.choosenCoordinates.isEmpty { + FileLocationMapView(coordinates: model.choosenCoordinates, label: locationLabel) + } + } + } + + } + + + @ViewBuilder + private var attributionSection: some View { + Section("License and Attribution") { + HStack { + Text("License") + Spacer() + Button { + isLicensePickerShowing = true + } label: { + if let license = model.info.multiDraft.license { + Text(license.abbreviation) + } else { + Text("choose") + } + } + .focused($focus, equals: .license) + + } + .sheet(isPresented: $isLicensePickerShowing) { + LicensePicker(selectedLicense: $model.info.multiDraft.license, allowsEmptySelection: false) + } + + + HStack { + // TODO: extend this, atleast with a helper text + // about what is ok to upload and what not. + + Text("Source") + Spacer() + Text("Own Work") + } + } + } + + @ViewBuilder + var imageCarouselView: some View { + ScrollView(.horizontal) { + LazyHGrid(rows: [.init(), .init(), .init()]) { + ForEach(model.info.drafts) { draft in + Button { + if let localFileRequestResized = draft.localFileRequestFull { + zoomableImageReference = .localImage(.init(image: localFileRequestResized, fullWidth: draft.width, fullHeight: draft.height, fullByte: nil)) + isZoomableImageViewerPresented = true + } else { + assertionFailure() + } + } label: { + LazyImage(request: draft.localFileRequestResized) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .transition(.blurReplace) + .clipShape(.containerRelative) + } else { + Color.clear.background(.regularMaterial) + } + } + } + .buttonStyle(ImageButtonStyle()) + + + } + } + .containerShape(ViewConstants.draftImageCarouselContainerShape) + } + .frame(maxHeight: 300) + .listRowInsets(.init()) + .listRowBackground(Color.clear) + + + // // we only expect the model.fileItem?.fileURL, but thumburl is useful for previews + // Button { + // isZoomableImageViewerPresented = true + // } label: { + // LazyImage(request: model.imageRequest) { phase in + // if let image = phase.image { + // image + // .resizable() + // .aspectRatio(contentMode: .fill) + // .transition(.blurReplace) + // .clipShape(.containerRelative) + // } else { + // Color.clear.background(.regularMaterial) + // } + // } + // } + .buttonStyle(ImageButtonStyle()) + // .containerRelativeFrame(.horizontal) + // .listRowInsets(.init()) + // .listRowBackground(Color.clear) + .zoomableImageFullscreenCover( + imageReference: zoomableImageReference, + isPresented: $isZoomableImageViewerPresented + ) + } + + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigation) { + Button("Close", systemImage: "xmark", role: .fallbackClose) { + if draftExistsInDB { + saveChangesAndDismiss() + dismiss() + } else { + isShowingCloseConfirmationDialog = true + } + } + .labelStyle(.iconOnly) + .confirmationDialog( + "Save draft for later or delete now?", + isPresented: $isShowingCloseConfirmationDialog, + titleVisibility: .visible + ) { + Button("Save Draft", systemImage: "square.and.arrow.down", role: .fallbackConfirm) { + saveChangesAndDismiss() + } + Button("Delete Draft", systemImage: "trash", role: .destructive) { + deleteDraftAndDismiss() + } + } + } + + if draftExistsInDB { + ToolbarItem(placement: .destructiveAction) { + Button("Delete", systemImage: "trash", role: .destructive) { + isShowingDeleteDialog = true + } + .confirmationDialog( + "Are you sure you want to delete the Draft?", + isPresented: $isShowingDeleteDialog, + titleVisibility: .visible + ) { + Button("Delete", systemImage: "trash", role: .destructive, action: deleteDraftAndDismiss) + + Button("Cancel", role: .cancel) { isShowingDeleteDialog = false } + } + } + } + + ToolbarItem(placement: .automatic) { + Button("Show File List", systemImage: "list.clipboard") { + isShowingFileListSheet = true + } + } + + + ToolbarItem(placement: .confirmationAction) { + if let username = account.activeUser?.username, model.info.multiDraft.uploadPossibleStatus == .uploadPossible { + Button { + isShowingUploadDialog = true + } label: { + Label("Upload", systemImage: "arrow.up") + } + .confirmationDialog("Start upload to Wikimedia Commons now?", isPresented: $isShowingUploadDialog, titleVisibility: .visible) { + Button("Upload", systemImage: "square.and.arrow.up", role: .fallbackConfirm) { + model.saveChanges(appDatabase: appDatabase) + uploadManager.upload(model.info, username: username) + dismiss() + } + + Button("Cancel", role: .cancel) { + isShowingDeleteDialog = false + } + } + } else { + Button { + isShowingUploadDisabledAlert = true + } label: { + Label("Info", systemImage: "arrow.up") + } + .tint(Color.gray.opacity(0.5)) + .alert( + "Upload not possible", isPresented: $isShowingUploadDisabledAlert, + actions: { + Button("Ok") { + switch model.info.multiDraft.uploadPossibleStatus { + case .uploadPossible: break + case .missingCaptionOrDescription: + focus = .caption + case .missingLicense: + focus = .license + case .missingTags: + focus = .tags + case .validationError(let nameValidationError): + focus = .filename + case .failedToValidate: break + case .none: break + } + } + }, + message: { + if account.activeUser == nil { + Text("You must be logged in to a Wikimedia account to upload files.") + } else { + switch model.info.multiDraft.uploadPossibleStatus { + case .uploadPossible: + Text("Unknown error, please make a screenshot and report this issue if you see this.") + case .missingCaptionOrDescription: + Text("Please provide a caption or description.") + case .missingLicense: + Text("You must choose the license under which you want to publish the file.") + case .missingTags: + Text("You should add atleast one category or depicted item in the Tags-section.") + case .validationError(let nameValidationError): + if let errorDescription = nameValidationError.errorDescription { + Text(errorDescription) + } + if let failureReason = nameValidationError.failureReason { + Text(failureReason) + } + case .failedToValidate: + Text("There was an error validating the file name.") + case nil: + Text("Currently checking if you can upload. please wait a short moment...") + } + } + }) + } + } + + + } +} + +extension MultiDraft { + var filenamePreviewWithCounter: AttributedString { + let attributedString = AttributedString(name) + var ending = AttributedString(", 01...99") + ending.foregroundColor = .accent + let finalString = attributedString + ending + AttributedString(".jpg") + return finalString + } +} + + +#Preview("New Draft", traits: .previewEnvironment) { + @Previewable @State var draft = MultiDraftModel(.makeRandom(id: 1, imageCount: 5)) + + MultiDraftView(model: draft) +} + +#Preview("With Metadata", traits: .previewEnvironment) { + @Previewable @State var draft = MultiDraftModel(.makeRandom(id: 1, imageCount: 5)) + + MultiDraftView(model: draft) +} diff --git a/CommonsFinder/Views/DraftViews/SingleDraftSheetModifier.swift b/CommonsFinder/Views/DraftViews/SingleDraftSheetModifier.swift new file mode 100644 index 0000000..08086d1 --- /dev/null +++ b/CommonsFinder/Views/DraftViews/SingleDraftSheetModifier.swift @@ -0,0 +1,21 @@ +// +// SingleDraftSheetModifier.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import SwiftUI + +struct SingleDraftSheetModifier: ViewModifier { + @Binding var draftedFileModel: SingleDraftModel? + + func body(content: Content) -> some View { + content + .sheet(item: $draftedFileModel) { model in + NavigationStack { + SingleDraftView(model: model) + } + } + } +} diff --git a/CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift b/CommonsFinder/Views/DraftViews/SingleDraftView.swift similarity index 77% rename from CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift rename to CommonsFinder/Views/DraftViews/SingleDraftView.swift index 4a96076..d00b5bb 100644 --- a/CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift +++ b/CommonsFinder/Views/DraftViews/SingleDraftView.swift @@ -15,8 +15,8 @@ import TipKit import UniformTypeIdentifiers import os.log -struct SingleImageDraftView: View { - @Bindable var model: MediaFileDraftModel +struct SingleDraftView: View { + @Bindable var model: SingleDraftModel @Environment(UploadManager.self) private var uploadManager @Environment(AccountModel.self) private var account @@ -37,6 +37,8 @@ struct SingleImageDraftView: View { @State private var isShowingUploadDialog = false @State private var isShowingCloseConfirmationDialog = false @State private var isShowingUploadDisabledAlert = false + @State private var isShowingTagsPicker = false + @State private var isShowingCategoryPicker = false private var draftExistsInDB: Bool { do { @@ -74,10 +76,10 @@ struct SingleImageDraftView: View { // NOTE: Not using a regular sheet here: .sheet + ScrollView + ForEach Buttons causes accidental button taps when scrolling (SwiftUI bug?) // so for now until this behaviour is fixed by Apple // this is a fullScreenCover (but TODO: consider using a push navigation here) - .fullScreenCover(isPresented: $model.isShowingTagsPicker) { + .fullScreenCover(isPresented: $isShowingTagsPicker) { TagPicker( initialTags: model.draft.tags, - draft: model.draft, + analysisInput: .draft(model.draft), onEditedTags: { model.draft.tags = $0 } @@ -97,7 +99,11 @@ struct SingleImageDraftView: View { if focus != .filename { generateFilename() } - model.draft.uploadPossibleStatus = model.canUploadDraft() + + model.draft.uploadPossibleStatus = DraftValidation.canUploadDraft( + model.draft, + nameValidationResult: model.nameValidationResult + ) } .onChange(of: model.draft.selectedFilenameType) { oldValue, newValue in filenameSelection = .none @@ -107,7 +113,7 @@ struct SingleImageDraftView: View { } .onDisappear { if draftExistsInDB, model.draft.publishingState == nil { - saveChanges() + model.saveChanges(appDatabase: appDatabase) } } .task(id: model.draft.name) { @@ -148,19 +154,8 @@ struct SingleImageDraftView: View { } } - private func saveChanges() { - do { - if let fileItem = model.fileItem { - model.draft.localFileName = fileItem.localFileName - } - try appDatabase.upsert(model.draft) - } catch { - logger.error("Failed to save all drafts \(error)") - } - } - private func saveChangesAndDismiss() { - saveChanges() + model.saveChanges(appDatabase: appDatabase) dismiss() } @@ -174,7 +169,7 @@ struct SingleImageDraftView: View { } @ViewBuilder private var captionAndDescriptionSection: some View { - Section("Caption and Description") { + Section("Descriptions") { let enumeratedDescs = Array(model.draft.captionWithDesc.enumerated()) let disabledLanguages = model.draft.captionWithDesc.map(\.languageCode) @@ -274,63 +269,24 @@ struct SingleImageDraftView: View { .tint(.primary) .padding(.trailing) Spacer(minLength: 0) - if model.nameValidationResult == nil { - ProgressView() - } else { - Button { - switch model.nameValidationResult { - case .success(_), .none: - // do nothing, alternatively, tell user, the full filename including name ending and - // that it was checked with the backend? - break - case .failure(_): - isFilenameErrorSheetPresented = true - } - } label: { - switch model.nameValidationResult { - case .failure(_), .none: - Image(systemName: "exclamationmark.circle") - .foregroundStyle(.red) - case .success(_): - Image(systemName: "checkmark.circle") - .foregroundStyle(.green) - } - } - .alert( - model.nameValidationResult?.alertTitle ?? "", isPresented: $isFilenameErrorSheetPresented, presenting: model.nameValidationResult?.error, - actions: { error in - if case .invalid(let localInvalidationError) = error, - localInvalidationError?.canBeAutoFixed == true, - model.draft.selectedFilenameType == .custom - { - Button("sanitize") { - filenameSelection = .none - model.draft.name = LocalFileNameValidation.sanitizeFileName(model.draft.name) - } - } - Button("Ok") { - let endIdx = model.draft.name.endIndex - focus = .filename - filenameSelection = .init(range: endIdx.. Void) { + init(initialTags: [TagItem], analysisInput: FileAnalysis.Input?, onEditedTags: @escaping ([TagItem]) -> Void) { self.initialTags = initialTags - self.analysisInput = .draft(draft) + self.analysisInput = analysisInput self.onEditedTags = onEditedTags } - init(initialTags: [TagItem], mediaFile: MediaFile, onEditedTags: @escaping ([TagItem]) -> Void) { - self.initialTags = initialTags - self.analysisInput = .mediaFile(mediaFile) - self.onEditedTags = onEditedTags - } - - init(initialTags: [TagItem], onEditedTags: @escaping ([TagItem]) -> Void) { - self.initialTags = initialTags - self.analysisInput = .none - self.onEditedTags = onEditedTags - } } struct SafeAreaBarFallback: ViewModifier { @@ -481,7 +471,7 @@ private struct NavHeader: View { #Preview(traits: .previewEnvironment) { - TagPicker(initialTags: [.init(.randomItem(id: "test"), pickedUsages: [.category, .depict])]) { pickedTags in + TagPicker(initialTags: [.init(.randomItem(id: "test"), pickedUsages: [.category, .depict])], analysisInput: nil) { pickedTags in print(pickedTags) } } diff --git a/CommonsFinder/Views/FileCreateView/TagPicker/TagPickerModel.swift b/CommonsFinder/Views/DraftViews/TagPicker/TagPickerModel.swift similarity index 100% rename from CommonsFinder/Views/FileCreateView/TagPicker/TagPickerModel.swift rename to CommonsFinder/Views/DraftViews/TagPicker/TagPickerModel.swift diff --git a/CommonsFinder/Views/FileCreateView/TimezonePicker.swift b/CommonsFinder/Views/DraftViews/TimezonePicker.swift similarity index 100% rename from CommonsFinder/Views/FileCreateView/TimezonePicker.swift rename to CommonsFinder/Views/DraftViews/TimezonePicker.swift diff --git a/CommonsFinder/Views/Extensions/CLLocationCoordinate2D+description.swift b/CommonsFinder/Views/Extensions/CLLocationCoordinate2D+description.swift new file mode 100644 index 0000000..e567970 --- /dev/null +++ b/CommonsFinder/Views/Extensions/CLLocationCoordinate2D+description.swift @@ -0,0 +1,16 @@ +// +// CLLocationCoordinate2D+description.swift +// CommonsFinder +// +// Created by Tom Brewe on 01.05.26. +// + +import CoreLocation + +extension CLLocationCoordinate2D: @retroactive CustomStringConvertible { + public var description: String { + let latSign = latitude.sign == .minus ? "-" : "" + let lonSign = longitude.sign == .minus ? "-" : "" + return "\(latSign)\(latitude), \(lonSign)\(longitude)" + } +} diff --git a/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift b/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift index 012f67a..1db11c2 100644 --- a/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift +++ b/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift @@ -9,30 +9,16 @@ import Foundation import Nuke -extension MediaFileDraftModel { +extension SingleDraftModel { var zoomableImageReference: ZoomableImageReference? { if let imageRequest { .localImage(.init(image: imageRequest, fullWidth: draft.width, fullHeight: draft.height, fullByte: nil)) } else { - nil - } } var imageRequest: ImageRequest? { - temporaryFileImageRequest ?? draft.localFileRequestFull - } - - private var temporaryFilePath: URL? { - fileItem?.fileURL - } - - private var temporaryFileImageRequest: ImageRequest? { - if let temporaryFilePath { - ImageRequest(url: temporaryFilePath) - } else { - nil - } + draft.localFileRequestFull } } diff --git a/CommonsFinder/Views/FileDetailView/FileDetailView.swift b/CommonsFinder/Views/FileDetailView/FileDetailView.swift index cb9fee6..04150ba 100644 --- a/CommonsFinder/Views/FileDetailView/FileDetailView.swift +++ b/CommonsFinder/Views/FileDetailView/FileDetailView.swift @@ -136,7 +136,7 @@ struct FileDetailView: View { Button { UIPasteboard.general.string = mediaFileInfo.mediaFile.name } label: { - Image(systemName: "clipboard") + Image(systemName: "document.on.document") Text("Copy Filename") Text(mediaFileInfo.mediaFile.name) } diff --git a/CommonsFinder/Views/FileEditView/FileEditView.swift b/CommonsFinder/Views/FileEditView/FileEditView.swift index 51c25cf..9cb587a 100644 --- a/CommonsFinder/Views/FileEditView/FileEditView.swift +++ b/CommonsFinder/Views/FileEditView/FileEditView.swift @@ -139,7 +139,7 @@ struct FileEditView: View { .fullScreenCover(isPresented: $isShowingTagsPicker) { TagPicker( initialTags: model?.tags ?? resolvedTags, - mediaFile: mediaFileInfo.mediaFile, + analysisInput: .mediaFile(mediaFileInfo.mediaFile), onEditedTags: { model?.tags = $0 } diff --git a/CommonsFinder/Views/Home/DraftsSection.swift b/CommonsFinder/Views/Home/DraftsSection.swift index 8c2f99d..0342e4e 100644 --- a/CommonsFinder/Views/Home/DraftsSection.swift +++ b/CommonsFinder/Views/Home/DraftsSection.swift @@ -8,68 +8,115 @@ import GRDBQuery import SwiftUI +private enum DraftWrapper: Equatable, Identifiable { + case single(MediaFileDraft) + case multi(MultiDraftInfo) + + var addedDate: Date { + switch self { + case .single(let mediaFileDraft): + mediaFileDraft.addedDate + case .multi(let multiDraftInfo): + multiDraftInfo.multiDraft.addedDate + } + } + + var id: String { + switch self { + case .single(let mediaFileDraft): + mediaFileDraft.id + case .multi(let multiDraftInfo): + String(multiDraftInfo.id ?? Int64(multiDraftInfo.hashValue)) + } + } +} + + struct DraftsSection: View { - let drafts: [MediaFileDraft] + private let allDrafts: [DraftWrapper] + + init(drafts: [MediaFileDraft], multiDrafts: [MultiDraftInfo]) { + let single = drafts.map { DraftWrapper.single($0) } + let multi = multiDrafts.map { DraftWrapper.multi($0) } + let allSorted = (single + multi).sorted(by: \.addedDate) + allDrafts = allSorted + } var body: some View { ScrollView(.horizontal) { - LazyHStack(spacing: 20) { - ForEach(drafts) { draft in - DraftFileListItem(draft: draft) + LazyHStack(alignment: .top, spacing: 20) { + ForEach(allDrafts) { draft in + switch draft { + case .multi(let draft): + MultiDraftListItem(multiDraftInfo: draft) + case .single(let draft): + DraftListItem(draft: draft) + } } } - .padding(.bottom) + .scenePadding() } - .scenePadding() } } #Preview("Regular Upload", traits: .previewEnvironment(uploadSimulation: .regular)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts + @Previewable @Query(AllMultiDraftsRequest()) var multiDrafts ScrollView(.vertical) { - DraftsSection(drafts: drafts) + DraftsSection(drafts: drafts, multiDrafts: multiDrafts) } .shadow(radius: 30) .task { _ = try? appDatabase.deleteAllDrafts() - try? appDatabase.upsert( + _ = try? appDatabase.upsert( .makeRandomDraft(id: "1", uploadPossibleStatus: .uploadPossible) ) + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 6, imageCount: 5, uploadPossibleStatus: .uploadPossible)) + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 5, imageCount: 4, uploadPossibleStatus: .uploadPossible)) + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 4, imageCount: 3, uploadPossibleStatus: .uploadPossible)) + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 3, imageCount: 2, uploadPossibleStatus: .uploadPossible)) } } #Preview("Error Upload", traits: .previewEnvironment(uploadSimulation: .withErrors)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts + @Previewable @Query(AllMultiDraftsRequest()) var multiDrafts ScrollView(.vertical) { - DraftsSection(drafts: drafts) + DraftsSection(drafts: drafts, multiDrafts: multiDrafts) } .shadow(radius: 30) .task { _ = try? appDatabase.deleteAllDrafts() - try? appDatabase.upsert( - .makeRandomDraft(id: "2", uploadPossibleStatus: .uploadPossible) - ) + _ = try? appDatabase.upsert( + .makeRandomDraft(id: "7", uploadPossibleStatus: .uploadPossible)) + + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 8, imageCount: 5, uploadPossibleStatus: .uploadPossible)) + } } #Preview("Previous Error Upload", traits: .previewEnvironment(uploadSimulation: .withErrors)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts + @Previewable @Query(AllMultiDraftsRequest()) var multiDrafts ScrollView(.vertical) { - DraftsSection(drafts: drafts) + DraftsSection(drafts: drafts, multiDrafts: multiDrafts) } .shadow(radius: 30) .task { _ = try? appDatabase.deleteAllDrafts() - try? appDatabase.upsert( + _ = try? appDatabase.upsert( .makeRandomDraft( - id: "2", uploadPossibleStatus: .uploadPossible, publishingState: PublishingState.unstashingFile(filekey: "12345"), - publishingError: PublishingError.error(errorDescription: "Some Error", recoverySuggestion: "Retry?")) + id: "9", uploadPossibleStatus: .uploadPossible, publishingState: MediaFileDraft.PublishingState.unstashingFile(filekey: "12345"), + publishingError: MediaFileDraft.PublishingError.error(errorDescription: "Some Error", recoverySuggestion: "Retry?")) ) + + + _ = try? appDatabase.upsertAndFetch(.makeRandom(id: 10, imageCount: 5, uploadPossibleStatus: .uploadPossible, finishedWithErrors: true)) } } diff --git a/CommonsFinder/Views/Home/HomeView.swift b/CommonsFinder/Views/Home/HomeView.swift index 90edd03..52cc662 100644 --- a/CommonsFinder/Views/Home/HomeView.swift +++ b/CommonsFinder/Views/Home/HomeView.swift @@ -7,6 +7,7 @@ import GRDBQuery import Nuke +import NukeUI import SwiftUI import TipKit @@ -14,7 +15,8 @@ struct HomeView: View { @Environment(Navigation.self) private var navigation @Environment(AccountModel.self) private var account - @Query(AllDraftsRequest()) private var drafts + @Query(AllSingleDraftsRequest()) private var drafts + @Query(AllMultiDraftsRequest()) private var multiDrafts @Query(AllRecentlyViewedMediaFileRequest(order: .desc, searchText: "")) private var recentlyViewedFiles @Query(AllBookmarksFileRequest(order: .desc, searchText: "")) private var bookmarkedFiles @Query(AllRecentlyViewedWikiItemsRequest()) private var recentlyViewedWikiItems @@ -27,8 +29,8 @@ struct HomeView: View { TipView(HomeTip()) .padding() - if !drafts.isEmpty { - DraftsSection(drafts: drafts) + if !drafts.isEmpty || !multiDrafts.isEmpty { + DraftsSection(drafts: drafts, multiDrafts: multiDrafts) .transition(.blurReplace) } @@ -81,6 +83,7 @@ struct HomeView: View { } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .animation(.default, value: drafts) + .animation(.default, value: multiDrafts) .animation(.default, value: recentlyViewedFiles) .animation(.default, value: account.activeUser) .toolbar { diff --git a/CommonsFinder/Views/Reusable Views/InlineMap.swift b/CommonsFinder/Views/Reusable Views/InlineMap.swift index af18827..9abdc10 100644 --- a/CommonsFinder/Views/Reusable Views/InlineMap.swift +++ b/CommonsFinder/Views/Reusable Views/InlineMap.swift @@ -209,21 +209,13 @@ struct InlineMap: View { } Menu("More...") { - Button("Copy Coordinates", systemImage: "clipboard") { - UIPasteboard.general.string = coordinate.coordinateString + Button("Copy Coordinates", systemImage: "document.on.document") { + UIPasteboard.general.string = coordinate.description } } } } -extension CLLocationCoordinate2D { - fileprivate var coordinateString: String { - let latSign = latitude.sign == .minus ? "-" : "" - let lonSign = longitude.sign == .minus ? "-" : "" - return "\(latSign)\(latitude), \(lonSign)\(longitude)" - } -} - #Preview { InlineMap(coordinate: .init(latitude: .init(48.8588), longitude: .init(2.2945)), item: nil) diff --git a/CommonsFinder/Views/Reusable Views/UploadErrorDetailsSheet.swift b/CommonsFinder/Views/Reusable Views/UploadErrorDetailsSheet.swift index e72e683..7a302ec 100644 --- a/CommonsFinder/Views/Reusable Views/UploadErrorDetailsSheet.swift +++ b/CommonsFinder/Views/Reusable Views/UploadErrorDetailsSheet.swift @@ -13,8 +13,8 @@ import os.log extension View { @ViewBuilder func publishingErrorDetailsSheet( - _ publishingStatus: PublishingState?, - _ error: PublishingError?, + _ publishingStatus: MediaFileDraft.PublishingState?, + _ error: MediaFileDraft.PublishingError?, isPresented: Binding, onEditDraft: @escaping () -> Void, onDeleteDraft: @escaping () -> Void, @@ -34,8 +34,8 @@ extension View { } private struct PublishingErrorDetailsSheetModifier: ViewModifier { - let publishingStatus: PublishingState? - let error: PublishingError? + let publishingStatus: MediaFileDraft.PublishingState? + let error: MediaFileDraft.PublishingError? @Binding var isPresented: Bool let onEditDraft: () -> Void let onDeleteDraft: () -> Void @@ -59,8 +59,8 @@ private struct PublishingErrorDetailsSheetModifier: ViewModifier { } private struct PublishingErrorDetailsSheet: View { - let publishingStatus: PublishingState? - let error: PublishingError? + let publishingStatus: MediaFileDraft.PublishingState? + let error: MediaFileDraft.PublishingError? let onEditDraft: () -> Void let onDeleteDraft: () -> Void diff --git a/CommonsFinder/Views/ViewConstants.swift b/CommonsFinder/Views/ViewConstants.swift index 2496ac4..24fc835 100644 --- a/CommonsFinder/Views/ViewConstants.swift +++ b/CommonsFinder/Views/ViewConstants.swift @@ -8,6 +8,7 @@ import SwiftUI struct ViewConstants { + static let draftImageCarouselContainerShape: RoundedRectangle = .rect(cornerRadius: 15) static let mapSheetContainerShape: RoundedRectangle = .rect(cornerRadius: 33) /// the maximum width or height of a zoomable image diff --git a/CommonsFinderShareExtension/Info.plist b/CommonsFinderShareExtension/Info.plist index 4265abd..b6d1cc6 100644 --- a/CommonsFinderShareExtension/Info.plist +++ b/CommonsFinderShareExtension/Info.plist @@ -9,9 +9,9 @@ NSExtensionActivationRule NSExtensionActivationSupportsImageWithMaxCount - 1 + 100 NSExtensionActivationSupportsMovieWithMaxCount - 1 + 100 NSExtensionPointIdentifier