From 1f3549b71e7b73719cfc672112fc3868b50ab2b0 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Wed, 11 Mar 2026 15:02:32 +0100 Subject: [PATCH 1/2] refactor models to separate importing files and drafting into model domains --- CommonsFinder.xcodeproj/project.pbxproj | 2 + CommonsFinder/ContentView.swift | 29 +++------ .../Database/Model/MediaFileDraft.swift | 9 ++- CommonsFinder/Localizable.xcstrings | 2 +- .../Observable Models/Navigation.swift | 16 +++-- .../FileCreateView/DraftSheetModifer.swift | 29 +++------ .../Model/FileImportModel.swift | 61 ++++--------------- .../Model/MediaFileDraftModel.swift | 11 ---- .../MultiDraftSheetModifier.swift | 24 ++++++++ .../SingleDraftSheetModifier.swift | 21 +++++++ .../Reusable Views/DraftFileListItem.swift | 2 +- 11 files changed, 94 insertions(+), 112 deletions(-) create mode 100644 CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift create mode 100644 CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift diff --git a/CommonsFinder.xcodeproj/project.pbxproj b/CommonsFinder.xcodeproj/project.pbxproj index d87d46b..e99083f 100644 --- a/CommonsFinder.xcodeproj/project.pbxproj +++ b/CommonsFinder.xcodeproj/project.pbxproj @@ -230,6 +230,8 @@ Views/FileCreateView/Model/FileImportModel.swift, Views/FileCreateView/Model/FileItem.swift, Views/FileCreateView/Model/MediaFileDraftModel.swift, + Views/FileCreateView/MultiDraftSheetModifier.swift, + Views/FileCreateView/SingleDraftSheetModifier.swift, Views/FileCreateView/SingleImageDraftView.swift, Views/FileCreateView/TagPicker/TagButton.swift, Views/FileCreateView/TagPicker/TagLabel.swift, diff --git a/CommonsFinder/ContentView.swift b/CommonsFinder/ContentView.swift index 432a4ba..919ae48 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(draftedFileModels: $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,14 @@ 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(drafts: drafts) } } diff --git a/CommonsFinder/Database/Model/MediaFileDraft.swift b/CommonsFinder/Database/Model/MediaFileDraft.swift index 976ce7a..5dbf1b4 100644 --- a/CommonsFinder/Database/Model/MediaFileDraft.swift +++ b/CommonsFinder/Database/Model/MediaFileDraft.swift @@ -232,7 +232,7 @@ 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 @@ -241,10 +241,15 @@ extension MediaFileDraft { 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 diff --git a/CommonsFinder/Localizable.xcstrings b/CommonsFinder/Localizable.xcstrings index 684a293..e82bfd1 100644 --- a/CommonsFinder/Localizable.xcstrings +++ b/CommonsFinder/Localizable.xcstrings @@ -1368,7 +1368,7 @@ } } }, - "Multiple files" : { + "Multiple files %lld" : { }, "Nearby Locations" : { diff --git a/CommonsFinder/Observable Models/Navigation.swift b/CommonsFinder/Observable Models/Navigation.swift index b393673..22dc06d 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: MediaFileDraftModel? + var isEditingMultipleDrafts: [MediaFileDraftModel]? var isAuthSheetOpen: AuthNavigationDestination? enum DraftSheetNavItem: Identifiable, Equatable { @@ -167,16 +169,20 @@ extension Navigation { path[tabItem] = [] } - func editDrafts(drafts: [MediaFileDraft]) { - isEditingDraft = .init(existingDrafts: drafts) + func editDraft(draft: MediaFileDraft) { + isEditingDraft = .init(existingDraft: draft) + } + + func editMultipleDrafts(drafts: [MediaFileDraft]) { + isEditingMultipleDrafts = drafts.map { .init(existingDraft: $0) } } 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/Views/FileCreateView/DraftSheetModifer.swift b/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift index c1c5962..dc6a030 100644 --- a/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift +++ b/CommonsFinder/Views/FileCreateView/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,19 +62,6 @@ 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, @@ -114,11 +99,11 @@ 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) + navigation.editMultipleDrafts(drafts: Array(importModel.importedDrafts.values)) } } diff --git a/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift b/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift index cbbbd39..316fc4e 100644 --- a/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift +++ b/CommonsFinder/Views/FileCreateView/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? @@ -44,24 +43,12 @@ enum DraftError: Error { } } - 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 +58,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 +73,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 +90,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 +109,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 +145,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/MediaFileDraftModel.swift b/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift index 1ad8bea..94ecb7f 100644 --- a/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift +++ b/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift @@ -34,17 +34,6 @@ import os.log 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 diff --git a/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift b/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift new file mode 100644 index 0000000..536f0ba --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift @@ -0,0 +1,24 @@ +// +// MultiDraftSheetModifier.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import SwiftUI + +struct MultiDraftSheetModifier: ViewModifier { + @Binding var draftedFileModels: [MediaFileDraftModel]? + + func body(content: Content) -> some View { + content + .sheet(item: $draftedFileModels) { model in + NavigationStack { + Color.red.overlay { + Text("Multiple files \(model.count)") + } + + } + } + } +} diff --git a/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift b/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift new file mode 100644 index 0000000..61fff4e --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/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: MediaFileDraftModel? + + func body(content: Content) -> some View { + content + .sheet(item: $draftedFileModel) { model in + NavigationStack { + SingleImageDraftView(model: model) + } + } + } +} diff --git a/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift b/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift index f154af1..17f6090 100644 --- a/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift +++ b/CommonsFinder/Views/Reusable Views/DraftFileListItem.swift @@ -27,7 +27,7 @@ struct DraftFileListItem: View { @State private var isShowingErrorSheet = false private func editDraft() { - navigationModel.editDrafts(drafts: [draft]) + navigationModel.editDraft(draft: draft) } private func showDeleteDialog() { From de4e6f2d5e5329ce4f883ff9339ddeb38026740f Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Tue, 17 Mar 2026 15:26:46 +0100 Subject: [PATCH 2/2] WIP: support for multifil --- CommonsFinder.xcodeproj/project.pbxproj | 18 +- CommonsFinder/ContentView.swift | 9 +- .../Database/AppDatabase+Queries.swift | 22 +- CommonsFinder/Database/AppDatabase.swift | 64 ++ .../Database/Model/Drafts/Draftable.swift | 17 + .../Model/{ => Drafts}/MediaFileDraft.swift | 40 +- .../Database/Model/Drafts/MultiDraft.swift | 104 +++ .../Model/Drafts/MultiDraftInfo.swift | 23 + .../Model/Drafts/Types/DraftAuthor.swift | 17 + .../Model/Drafts/Types/DraftSource.swift | 19 + .../Model/Drafts/Types/LocationHandling.swift | 19 + .../MediaFileInfo+ImageRequest.swift | 8 + .../MultiDraftInfo+DebugDraft.swift | 49 ++ .../Database/Model/ItemInteraction.swift | 2 +- .../MKCoordinateRegion+extras.swift | 20 + CommonsFinder/Localizable.xcstrings | 22 +- .../Observable Models/Navigation.swift | 8 +- .../MediaFileDraft+uploadDisabledReason.swift | 87 --- .../NameValidationError.swift} | 86 +-- CommonsFinder/Utilities/ValidationUtils.swift | 80 ++ .../MediaFileDraftModel+ImageRequest.swift | 16 +- .../FileCreateView/DraftSheetModifer.swift | 8 +- .../FileCreateView/FileLocationMapView.swift | 61 ++ .../Model/FileImportModel.swift | 3 - .../Model/MultiDraftModel.swift | 55 ++ .../Model/SingleDraftModel.swift | 79 ++ .../MultiDraftSheetModifier.swift | 9 +- .../Views/FileCreateView/MultiDraftView.swift | 707 ++++++++++++++++++ .../SingleDraftSheetModifier.swift | 4 +- ...eDraftView.swift => SingleDraftView.swift} | 58 +- CommonsFinder/Views/Home/DraftsSection.swift | 6 +- CommonsFinder/Views/Home/HomeView.swift | 53 +- CommonsFinder/Views/ViewConstants.swift | 1 + CommonsFinderShareExtension/Info.plist | 4 +- 34 files changed, 1486 insertions(+), 292 deletions(-) create mode 100644 CommonsFinder/Database/Model/Drafts/Draftable.swift rename CommonsFinder/Database/Model/{ => Drafts}/MediaFileDraft.swift (85%) create mode 100644 CommonsFinder/Database/Model/Drafts/MultiDraft.swift create mode 100644 CommonsFinder/Database/Model/Drafts/MultiDraftInfo.swift create mode 100644 CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift create mode 100644 CommonsFinder/Database/Model/Drafts/Types/DraftSource.swift create mode 100644 CommonsFinder/Database/Model/Drafts/Types/LocationHandling.swift create mode 100644 CommonsFinder/Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift delete mode 100644 CommonsFinder/Types/MediaFileDraft+uploadDisabledReason.swift rename CommonsFinder/{Views/FileCreateView/Model/MediaFileDraftModel.swift => Types/NameValidationError.swift} (61%) create mode 100644 CommonsFinder/Views/FileCreateView/FileLocationMapView.swift create mode 100644 CommonsFinder/Views/FileCreateView/Model/MultiDraftModel.swift create mode 100644 CommonsFinder/Views/FileCreateView/Model/SingleDraftModel.swift create mode 100644 CommonsFinder/Views/FileCreateView/MultiDraftView.swift rename CommonsFinder/Views/FileCreateView/{SingleImageDraftView.swift => SingleDraftView.swift} (94%) diff --git a/CommonsFinder.xcodeproj/project.pbxproj b/CommonsFinder.xcodeproj/project.pbxproj index e99083f..1988b52 100644 --- a/CommonsFinder.xcodeproj/project.pbxproj +++ b/CommonsFinder.xcodeproj/project.pbxproj @@ -128,17 +128,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", @@ -186,7 +193,7 @@ Types/FileNameType.swift, Types/FileNameTypeTuple.swift, Types/ImageAnalysisResult.swift, - "Types/MediaFileDraft+uploadDisabledReason.swift", + Types/NameValidationError.swift, Types/NewDraftOptions.swift, Types/OptionBarState.swift, Types/ScrollState.swift, @@ -225,14 +232,17 @@ "Views/Extensions/MediaFileDraftModel+ImageRequest.swift", "Views/Extensions/UserDefaults+accessors.swift", Views/FileCreateView/DraftSheetModifer.swift, + Views/FileCreateView/FileLocationMapView.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/Model/MultiDraftModel.swift, + Views/FileCreateView/Model/SingleDraftModel.swift, Views/FileCreateView/MultiDraftSheetModifier.swift, + Views/FileCreateView/MultiDraftView.swift, Views/FileCreateView/SingleDraftSheetModifier.swift, - Views/FileCreateView/SingleImageDraftView.swift, + Views/FileCreateView/SingleDraftView.swift, Views/FileCreateView/TagPicker/TagButton.swift, Views/FileCreateView/TagPicker/TagLabel.swift, Views/FileCreateView/TagPicker/TagModel.swift, diff --git a/CommonsFinder/ContentView.swift b/CommonsFinder/ContentView.swift index 919ae48..997429d 100644 --- a/CommonsFinder/ContentView.swift +++ b/CommonsFinder/ContentView.swift @@ -56,7 +56,7 @@ struct ContentView: View { .sheet(item: $navigation.isAuthSheetOpen, content: AuthView.init) .modifier(ImportFilesModifer(importModel: $navigation.isImportingFiles)) .modifier(SingleDraftSheetModifier(draftedFileModel: $navigation.isEditingDraft)) - .modifier(MultiDraftSheetModifier(draftedFileModels: $navigation.isEditingMultipleDrafts)) + .modifier(MultiDraftSheetModifier(multiDraftModel: $navigation.isEditingMultipleDrafts)) .onOpenURL(perform: handleURL) .onContinueUserActivity(NSUserActivityTypeLiveActivity) { userActivity in guard let url = userActivity.webpageURL else { return } @@ -132,10 +132,9 @@ struct ContentView: View { navigation.selectedTab = .home if drafts.count > 1 { - // TODO: needs batch image implementation - - } else { - navigation.editMultipleDrafts(drafts: drafts) + navigation.editMultipleDrafts(multiDraftInfo: .init(multiDraft: .init(), 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..429adc7 100644 --- a/CommonsFinder/Database/AppDatabase+Queries.swift +++ b/CommonsFinder/Database/AppDatabase+Queries.swift @@ -12,17 +12,35 @@ 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 MultiDraft + .all() + .order(MultiDraft.Columns.addedDate.desc) + .including(all: MultiDraft.drafts) + .asRequest(of: MultiDraftInfo.self) + .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..9a64343 100644 --- a/CommonsFinder/Database/AppDatabase.swift +++ b/CommonsFinder/Database/AppDatabase.swift @@ -250,7 +250,30 @@ nonisolated final class AppDatabase: Sendable { t.add(column: "publishingState", .jsonText) t.add(column: "publishingStateVerificationRequired", .boolean) t.add(column: "publishingError", .jsonText) + + } + } + + migrator.registerMigration("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("selectedFilenameType") + t.column("uploadPossibleStatus") } + + try db.alter(table: "mediaFileDraft") { t in + t.add(column: "multiDraftId", .integer) + .references("multiDraft", onDelete: .cascade) + } } return migrator @@ -697,6 +720,38 @@ extension AppDatabase { } } +// MARK: - MultiDraftInfo Writes +extension AppDatabase { + func upsert(_ multiDraftInfo: MultiDraftInfo) throws { + 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) + } + } + } + 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: - MediaFileDraft Writes extension AppDatabase { @@ -878,6 +933,15 @@ nonisolated extension AppDatabase { .fetchAll(db) } } + + func fetchAllMultiDraftInfos() throws -> [MultiDraftInfo] { + try dbWriter.read { db in + try MultiDraft + .including(all: MultiDraft.drafts) + .asRequest(of: MultiDraftInfo.self) + .fetchAll(db) + } + } func fetchInterruptedDraftsRequiringVerification() throws -> [MediaFileDraft] { try dbWriter.read { db in diff --git a/CommonsFinder/Database/Model/Drafts/Draftable.swift b/CommonsFinder/Database/Model/Drafts/Draftable.swift new file mode 100644 index 0000000..c7adc79 --- /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 85% rename from CommonsFinder/Database/Model/MediaFileDraft.swift rename to CommonsFinder/Database/Model/Drafts/MediaFileDraft.swift index 5dbf1b4..3bf7062 100644 --- a/CommonsFinder/Database/Model/MediaFileDraft.swift +++ b/CommonsFinder/Database/Model/Drafts/MediaFileDraft.swift @@ -5,7 +5,7 @@ // Created by Tom Brewe on 21.01.25. // -import CommonsAPI + import CoreGraphics import CoreLocation import Foundation @@ -21,7 +21,7 @@ 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 let id: String @@ -61,15 +61,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,22 +69,8 @@ nonisolated var width: Int? var height: Int? - - enum DraftAuthor: Codable, Equatable, Hashable { - case appUser - case custom(name: String, wikimediaUsername: String?, url: URL?) - case wikidataId(wikidataItem: WikidataItemID) - } - - 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) - } + + var multiDraftId: Int64? } nonisolated @@ -143,6 +120,7 @@ nonisolated case source case width case height + case multiDraftId } // Define database columns from CodingKeys @@ -166,6 +144,7 @@ nonisolated 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 +161,16 @@ 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.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 { diff --git a/CommonsFinder/Database/Model/Drafts/MultiDraft.swift b/CommonsFinder/Database/Model/Drafts/MultiDraft.swift new file mode 100644 index 0000000..024bf20 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/MultiDraft.swift @@ -0,0 +1,104 @@ +// +// 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 { + let id: Int64? + 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? + + + 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 + } +} + +extension MultiDraft { + init() { + id = nil + addedDate = .now + name = "" + nameSuffix = .numbering + nameAdditionalFallbackSuffix = .asciiLetters + captionWithDesc = [] + tags = [] + license = nil + author = nil + source = nil + locationHandling = nil + 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 + + } + + // 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..931a488 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/MultiDraftInfo.swift @@ -0,0 +1,23 @@ +// +// 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 id: MultiDraft.ID { + multiDraft.id + } + + init(multiDraft: MultiDraft, drafts: [MediaFileDraft]) { + self.multiDraft = multiDraft + self.drafts = drafts + } +} diff --git a/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift b/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift new file mode 100644 index 0000000..9acfdd6 --- /dev/null +++ b/CommonsFinder/Database/Model/Drafts/Types/DraftAuthor.swift @@ -0,0 +1,17 @@ +// +// DraftAuthor.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import Foundation +import CommonsAPI + +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..ef363da --- /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 Foundation +import CommonsAPI + +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..c3ab1d2 --- /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 Foundation +import CoreLocation + + 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..970fef9 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..54a9763 --- /dev/null +++ b/CommonsFinder/Database/Model/Extensions/MultiDraftInfo+DebugDraft.swift @@ -0,0 +1,49 @@ +// +// 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) -> Self { + let date: Date = Date(timeIntervalSince1970: .random(in: 1..<5000)) + + let randomMultiDraft = MultiDraft( + id: id, + addedDate: date, + name: "", + nameSuffix: .numbering, + nameAdditionalFallbackSuffix: .asciiLetters, + captionWithDesc: [], + tags: [], + license: nil, + author: nil, + source: .own, + selectedFilenameType: .captionAndDate, + uploadPossibleStatus: nil, + ) + + let randomDrafts: [MediaFileDraft] = [1...5].map { + var draft = MediaFileDraft.makeRandomDraft(id: "\($0)") + draft.multiDraftId = id + return draft + } + + return MultiDraftInfo( + multiDraft: randomMultiDraft, + drafts: randomDrafts + ) + } +} diff --git a/CommonsFinder/Database/Model/ItemInteraction.swift b/CommonsFinder/Database/Model/ItemInteraction.swift index d4a7696..ee7b928 100644 --- a/CommonsFinder/Database/Model/ItemInteraction.swift +++ b/CommonsFinder/Database/Model/ItemInteraction.swift @@ -8,7 +8,7 @@ import Foundation import GRDB -/// `ItemInteraction` stores interaction metada for MediaFile and Category +/// `ItemInteraction` stores interaction metadata for MediaFile and Category /// eg. `lastViewed` or `viewCount`, `isBookmarked` nonisolated struct ItemInteraction: Equatable, Hashable, Sendable, Identifiable { var id: Int64? diff --git a/CommonsFinder/Generic Extensions/MKCoordinateRegion+extras.swift b/CommonsFinder/Generic Extensions/MKCoordinateRegion+extras.swift index 7424833..f6c5d96 100644 --- a/CommonsFinder/Generic Extensions/MKCoordinateRegion+extras.swift +++ b/CommonsFinder/Generic Extensions/MKCoordinateRegion+extras.swift @@ -6,6 +6,9 @@ // import MapKit +import GEOSwift +import CoreLocation +import GEOSwiftMapKit extension MKCoordinateRegion { var metersInLatitude: Double { @@ -109,5 +112,22 @@ extension MKCoordinateRegion { return (northEast, southWest) } + + init(containing geometry: GeometryConvertible, paddingFactor: Double, minPadding: CLLocationDistance) throws { + + let envelope = try geometry.geometry.envelope() + let latPadding = (envelope.maxY - envelope.minY) * paddingFactor + let lonPadding = (envelope.maxX - envelope.minX) * paddingFactor + let center = try CLLocationCoordinate2D(envelope.geometry.centroid()) + let minPaddingDegrees = GeoVectorMath.degrees(fromMeters: minPadding, atLatitude: center.latitude) + + let span = MKCoordinateSpan( + latitudeDelta: max(minPaddingDegrees.latitudeDegrees, latPadding) + envelope.maxY - envelope.minY, + longitudeDelta: max(minPaddingDegrees.longitudeDegrees, lonPadding) + envelope.maxX - envelope.minX) + + + + self.init(center: center, span: span) + } } diff --git a/CommonsFinder/Localizable.xcstrings b/CommonsFinder/Localizable.xcstrings index e82bfd1..7e5b336 100644 --- a/CommonsFinder/Localizable.xcstrings +++ b/CommonsFinder/Localizable.xcstrings @@ -53,6 +53,9 @@ } } } + }, + "%lld files with locations." : { + }, "%lld Parent Categories" : { "localizations" : { @@ -133,6 +136,9 @@ } } } + }, + "%lld/250 characters" : { + }, "%lld×%lld pixel" : { "localizations" : { @@ -429,6 +435,7 @@ } }, "Caption and Description" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -767,6 +774,12 @@ } } } + }, + "Description" : { + + }, + "Descriptions" : { + }, "detailed description (optional)" : { "localizations" : { @@ -787,6 +800,9 @@ } } } + }, + "Draft (%lld files)" : { + }, "E-Mail Address" : { "localizations" : { @@ -1207,6 +1223,9 @@ } } } + }, + "Location metadata will be erased from all %lld files before uploading." : { + }, "Location will be erased from the file metadata before uploading." : { "localizations" : { @@ -1367,9 +1386,6 @@ } } } - }, - "Multiple files %lld" : { - }, "Nearby Locations" : { diff --git a/CommonsFinder/Observable Models/Navigation.swift b/CommonsFinder/Observable Models/Navigation.swift index 22dc06d..ab20aad 100644 --- a/CommonsFinder/Observable Models/Navigation.swift +++ b/CommonsFinder/Observable Models/Navigation.swift @@ -74,8 +74,8 @@ import os.log // var isViewingFileSheetOpen: MediaFile.ID? var isImportingFiles: FileImportModel? - var isEditingDraft: MediaFileDraftModel? - var isEditingMultipleDrafts: [MediaFileDraftModel]? + var isEditingDraft: SingleDraftModel? + var isEditingMultipleDrafts: MultiDraftModel? var isAuthSheetOpen: AuthNavigationDestination? enum DraftSheetNavItem: Identifiable, Equatable { @@ -173,8 +173,8 @@ extension Navigation { isEditingDraft = .init(existingDraft: draft) } - func editMultipleDrafts(drafts: [MediaFileDraft]) { - isEditingMultipleDrafts = drafts.map { .init(existingDraft: $0) } + func editMultipleDrafts(multiDraftInfo: MultiDraftInfo) { + isEditingMultipleDrafts = .init(multiDraftInfo) } func openNewDraft(options: NewDraftOptions) { 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 61% rename from CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift rename to CommonsFinder/Types/NameValidationError.swift index 94ecb7f..2e64563 100644 --- a/CommonsFinder/Views/FileCreateView/Model/MediaFileDraftModel.swift +++ b/CommonsFinder/Types/NameValidationError.swift @@ -1,92 +1,12 @@ // -// 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() - }() - - /// 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 diff --git a/CommonsFinder/Utilities/ValidationUtils.swift b/CommonsFinder/Utilities/ValidationUtils.swift index 9d92dda..9c728e6 100644 --- a/CommonsFinder/Utilities/ValidationUtils.swift +++ b/CommonsFinder/Utilities/ValidationUtils.swift @@ -6,6 +6,86 @@ // import Foundation +import UniformTypeIdentifiers +import CommonsAPI +import os.log + +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 + } + } + + /// validates a name (without "File:"-prefix and without file-ending ) + static func validateFilename(name: String, mimeType: String) async -> NameValidationResult { + let localValidationResult = LocalFileNameValidation.validateFileName(name) + return switch localValidationResult { + case .success: + // if local validation was successful, check again with API + await validateFilenameWithAPI(name, mimeType: mimeType) ?? .success(()) + case .failure(let error): + .failure(.invalid(error)) + } + } + /// validates a filename (without "File:"-prefix & without file-type suffix (eg. .jpg) + private static func validateFilenameWithAPI(_ 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 nil + } + + // The API operates on filenames with type-endings (.jpg, .png, etc.) + let filename = 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) + } + } +} nonisolated enum EmailValidation { static func isValidEmailAddress(string: String) -> Bool { diff --git a/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift b/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift index 012f67a..4e297a4 100644 --- a/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift +++ b/CommonsFinder/Views/Extensions/MediaFileDraftModel+ImageRequest.swift @@ -9,7 +9,7 @@ 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)) @@ -21,18 +21,6 @@ extension MediaFileDraftModel { } 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/FileCreateView/DraftSheetModifer.swift b/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift index dc6a030..5ae4627 100644 --- a/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift +++ b/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift @@ -103,14 +103,18 @@ struct ImportFilesModifer: ViewModifier { if fileCount == 1, let newDraft = importModel.importedDrafts.values.first { navigation.editDraft(draft: newDraft) } else if fileCount > 1 { - navigation.editMultipleDrafts(drafts: Array(importModel.importedDrafts.values)) + let info = MultiDraftInfo( + multiDraft: .init(), + 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/FileCreateView/FileLocationMapView.swift b/CommonsFinder/Views/FileCreateView/FileLocationMapView.swift new file mode 100644 index 0000000..1e94459 --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/FileLocationMapView.swift @@ -0,0 +1,61 @@ +// +// FileLocationMapView.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + +import CoreLocation +import SwiftUI +import MapKit +import GEOSwift +import GEOSwiftMapKit + +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/FileCreateView/Model/FileImportModel.swift b/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift index 316fc4e..8be0f50 100644 --- a/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift +++ b/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift @@ -34,9 +34,6 @@ 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) diff --git a/CommonsFinder/Views/FileCreateView/Model/MultiDraftModel.swift b/CommonsFinder/Views/FileCreateView/Model/MultiDraftModel.swift new file mode 100644 index 0000000..80786d8 --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/Model/MultiDraftModel.swift @@ -0,0 +1,55 @@ +// +// MultiDraftModel.swift +// CommonsFinder +// +// Created by Tom Brewe on 11.03.26. +// + + +import CommonsAPI +import CoreLocation +import Foundation +import Nuke +import UniformTypeIdentifiers +import os.log + +// TODO: perhaps consolidate as view state directly +@Observable final class MultiDraftModel: @preconcurrency Identifiable { + typealias ID = String + var id: ID + var info: MultiDraftInfo + + var suggestedFilenames: [FileNameTypeTuple] = [] + var nameValidationResult: [MediaFileDraft.ID: 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: + [] + } + } + + @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 = [:] + } +} diff --git a/CommonsFinder/Views/FileCreateView/Model/SingleDraftModel.swift b/CommonsFinder/Views/FileCreateView/Model/SingleDraftModel.swift new file mode 100644 index 0000000..ad83dd2 --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/Model/SingleDraftModel.swift @@ -0,0 +1,79 @@ +// +// 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) + } +} + +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/FileCreateView/MultiDraftSheetModifier.swift b/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift index 536f0ba..fd9feee 100644 --- a/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift +++ b/CommonsFinder/Views/FileCreateView/MultiDraftSheetModifier.swift @@ -8,16 +8,13 @@ import SwiftUI struct MultiDraftSheetModifier: ViewModifier { - @Binding var draftedFileModels: [MediaFileDraftModel]? + @Binding var multiDraftModel: MultiDraftModel? func body(content: Content) -> some View { content - .sheet(item: $draftedFileModels) { model in + .sheet(item: $multiDraftModel) { model in NavigationStack { - Color.red.overlay { - Text("Multiple files \(model.count)") - } - + MultiDraftView(model: model) } } } diff --git a/CommonsFinder/Views/FileCreateView/MultiDraftView.swift b/CommonsFinder/Views/FileCreateView/MultiDraftView.swift new file mode 100644 index 0000000..ab90d1c --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/MultiDraftView.swift @@ -0,0 +1,707 @@ +// +// 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 isFilenameErrorSheetPresented = false + @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 + + private var draftExistsInDB: Bool { + model.info.multiDraft.id != nil + } + + private enum FocusElement: Hashable { + case caption + case description + case tags + case license + case filename + } + + 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) { + TagPicker( + initialTags: model.info.multiDraft.tags, + onEditedTags: { + model.info.multiDraft.tags = $0 + } + ) + } +// .sheet(isPresented: $isTimezonePickerShowing) { +// TimezonePicker(selectedTimezone: $model.multiDraft.timezone) +// .presentationDetents([.medium, .large]) +// } + + .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.values.first + ) + } + .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.multiDraft.name) { +// do { +// try await model.validateFilenameImpl() +// } catch { +// logger.error("Failed to validate name \(error)") +// } +// } +// .task(id: model.multiDraft.id) { +// fileAnalysis.startAnalyzingIfNeeded(model.multiDraft) +// } +// .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 saveChanges() { + do { + try appDatabase.upsert(model.info) + } catch { + logger.error("Failed to save all drafts \(error)") + } + } + + private func saveChangesAndDismiss() { + saveChanges() + 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 { + // FIXME: only check first and last filename, dynamically + // check all filenames when uploading? + 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 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.multiDraft.selectedFilenameType == .custom +// { +// Button("sanitize") { +// filenameSelection = .none +// model.multiDraft.name = LocalFileNameValidation.sanitizeFileName(model.multiDraft.name) +// } +// } +// Button("Ok") { +// let endIdx = model.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 { + isZoomableImageViewerPresented = true + } 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: model.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: .confirmationAction) { + if 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) { + guard let username = account.activeUser?.username else { + assertionFailure() + return + } + saveChanges() + // FIXME: actual upload +// uploadManager.upload(model.info.multiDraft, 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 .notLoggedIn: 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: { + switch model.info.multiDraft.uploadPossibleStatus { + case .uploadPossible: + Text("Unknown error, please make a screenshot and report this issue if you see this.") + case .notLoggedIn: + Text("You must be logged in to a Wikimedia account to upload files.") + 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...") + } + }) + } + } + + + } +} + + +#Preview("New Draft", traits: .previewEnvironment) { + @Previewable @State var draft = MultiDraftModel(.makeRandom(id: 1)) + + MultiDraftView(model: draft) +} + +#Preview("With Metadata", traits: .previewEnvironment) { + @Previewable @State var draft = MultiDraftModel(.makeRandom(id: 1)) + + MultiDraftView(model: draft) +} diff --git a/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift b/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift index 61fff4e..08086d1 100644 --- a/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift +++ b/CommonsFinder/Views/FileCreateView/SingleDraftSheetModifier.swift @@ -8,13 +8,13 @@ import SwiftUI struct SingleDraftSheetModifier: ViewModifier { - @Binding var draftedFileModel: MediaFileDraftModel? + @Binding var draftedFileModel: SingleDraftModel? func body(content: Content) -> some View { content .sheet(item: $draftedFileModel) { model in NavigationStack { - SingleImageDraftView(model: model) + SingleDraftView(model: model) } } } diff --git a/CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift b/CommonsFinder/Views/FileCreateView/SingleDraftView.swift similarity index 94% rename from CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift rename to CommonsFinder/Views/FileCreateView/SingleDraftView.swift index 4a96076..6d55cc6 100644 --- a/CommonsFinder/Views/FileCreateView/SingleImageDraftView.swift +++ b/CommonsFinder/Views/FileCreateView/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,7 +76,7 @@ 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, @@ -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 @@ -150,9 +156,6 @@ 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)") @@ -174,7 +177,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) @@ -406,7 +409,7 @@ struct SingleImageDraftView: View { HFlowLayout(alignment: .leading) { ForEach(tags) { tag in Button { - model.isShowingTagsPicker = true + isShowingTagsPicker = true } label: { TagLabel(tag: tag) } @@ -423,7 +426,7 @@ struct SingleImageDraftView: View { model.draft.tags.isEmpty ? "Add" : "Edit", systemImage: model.draft.tags.isEmpty ? "plus" : "pencil" ) { - model.isShowingTagsPicker = true + isShowingTagsPicker = true } .focused($focus, equals: .tags) } header: { @@ -457,7 +460,7 @@ struct SingleImageDraftView: View { Text("Location will be erased from the file metadata before uploading.") .font(.caption) } else if let coordinate = model.choosenCoordinate { - FileLocationMapView(coordinate: coordinate, label: locationLabel) + FileLocationMapView(coordinates: [coordinate], label: locationLabel) } } } @@ -699,41 +702,16 @@ struct SingleImageDraftView: View { } } -struct FileLocationMapView: View { - let coordinate: CLLocationCoordinate2D - var label: String? - - @State private var markerLabel: String? - - var body: some View { - let halfKmRadius = MKCoordinateRegion( - center: coordinate, - latitudinalMeters: 500, - longitudinalMeters: 500 - ) - - Map(initialPosition: .region(halfKmRadius)) { - Marker(label ?? "", coordinate: coordinate) - } - .mapControlVisibility(.automatic) - .allowsHitTesting(false) - .frame(height: 150) - .clipShape(.rect(cornerRadius: 15)) - - - } -} - #Preview("New Draft", traits: .previewEnvironment) { - @Previewable @State var draft = MediaFileDraftModel(existingDraft: .makeRandomEmptyDraft(id: "1")) + @Previewable @State var draft = SingleDraftModel(existingDraft: .makeRandomEmptyDraft(id: "1")) - SingleImageDraftView(model: draft) + SingleDraftView(model: draft) } #Preview("With Metadata", traits: .previewEnvironment) { - @Previewable @State var draft = MediaFileDraftModel(existingDraft: .makeRandomDraft(id: "2")) + @Previewable @State var draft = SingleDraftModel(existingDraft: .makeRandomDraft(id: "2")) - SingleImageDraftView(model: draft) + SingleDraftView(model: draft) } diff --git a/CommonsFinder/Views/Home/DraftsSection.swift b/CommonsFinder/Views/Home/DraftsSection.swift index 8c2f99d..ab45f39 100644 --- a/CommonsFinder/Views/Home/DraftsSection.swift +++ b/CommonsFinder/Views/Home/DraftsSection.swift @@ -26,7 +26,7 @@ struct DraftsSection: View { #Preview("Regular Upload", traits: .previewEnvironment(uploadSimulation: .regular)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts ScrollView(.vertical) { DraftsSection(drafts: drafts) @@ -42,7 +42,7 @@ struct DraftsSection: View { #Preview("Error Upload", traits: .previewEnvironment(uploadSimulation: .withErrors)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts ScrollView(.vertical) { DraftsSection(drafts: drafts) @@ -58,7 +58,7 @@ struct DraftsSection: View { #Preview("Previous Error Upload", traits: .previewEnvironment(uploadSimulation: .withErrors)) { @Previewable @Environment(\.appDatabase) var appDatabase - @Previewable @Query(AllDraftsRequest()) var drafts + @Previewable @Query(AllSingleDraftsRequest()) var drafts ScrollView(.vertical) { DraftsSection(drafts: drafts) diff --git a/CommonsFinder/Views/Home/HomeView.swift b/CommonsFinder/Views/Home/HomeView.swift index 90edd03..762cb78 100644 --- a/CommonsFinder/Views/Home/HomeView.swift +++ b/CommonsFinder/Views/Home/HomeView.swift @@ -9,12 +9,14 @@ import GRDBQuery import Nuke import SwiftUI import TipKit +import NukeUI 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,6 +29,8 @@ struct HomeView: View { TipView(HomeTip()) .padding() + multiDraftsDebug + if !drafts.isEmpty { DraftsSection(drafts: drafts) .transition(.blurReplace) @@ -116,6 +120,53 @@ struct HomeView: View { .navigationBarTitleDisplayMode(.inline) // .toolbar(removing: .title) } + + @ViewBuilder + private var multiDraftsDebug: some View { + if !multiDrafts.isEmpty { + ScrollView(.horizontal) { + HStack { + ForEach(multiDrafts) { multiDraftInfo in + Button { + navigation.editMultipleDrafts(multiDraftInfo: multiDraftInfo) + } label: { + + + LazyHGrid(rows: [.init(), .init()]) { + ForEach(multiDraftInfo.drafts) { draft in + + LazyImage(request: draft.localFileRequestResizedGridThumb, transaction: .init(animation: .linear(duration: 0.3))) { state in + Color.clear + .frame(width: 100, height: 100) + .overlay { + if let image = state.image { + image + .resizable() + .scaledToFill() + + + } + } + .clipped() + + } + + + + } + } + .clipShape(.rect(cornerRadius: 16)) + .padding() + .background(Color.cardBackground) + .clipShape(.rect(cornerRadius: 23)) + + } + } + } + } + + } + } } 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