From 766e8e9e7b75d75bcb1e31cb6c93f2f219e3bebd Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Thu, 19 Feb 2026 17:44:05 +0100 Subject: [PATCH 01/13] Add QR code and installation info to README Updated README to include QR code for TestFlight installation. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4269d68..5cd19e4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # App icon of CommonsFinder CommonsFinder CommonsFinder is an iOS app to explore and upload media to [Wikimedia Commons](https://commons.wikimedia.org). +It is currently in beta and can be installed via TestFlight: https://testflight.apple.com/join/15KtE2Mn (or scan the QR-Code) + +[Testflight QR-Code](https://testflight.apple.com/join/15KtE2Mn) -CommonsFinder is currently in beta and can be installed via TestFlight: https://testflight.apple.com/join/15KtE2Mn -Or scan the QR-Code with your iPhone: [[Testflight QR Code](Testflight-QR-Code.png)](https://testflight.apple.com/join/15KtE2Mn)! ## Features From 910ea2301f48a80994b722b376ad3f68dbe4da6b Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Thu, 19 Feb 2026 18:18:46 +0100 Subject: [PATCH 02/13] chore: prepare testflight notes --- TestFlight/WhatToTest.de-DE.txt | 18 +++++++++++++++++- TestFlight/WhatToTest.en-US.txt | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/TestFlight/WhatToTest.de-DE.txt b/TestFlight/WhatToTest.de-DE.txt index 787f23e..c3d2118 100644 --- a/TestFlight/WhatToTest.de-DE.txt +++ b/TestFlight/WhatToTest.de-DE.txt @@ -1 +1,17 @@ -Diese Version behebt einen Fehler, der Auftritt wenn Bilder geladen werden, deren Dateinamen ein "+" enthalten. +NEUE FEATURES + +- Dateien können jetzt bearbeitet werden (Kurzbeschreibung, Kategorien, Motiv)! +- Suchergebnisse im Such-Tab sind jetzt Sortierbar nach neuesten/ältesten Ergebnissen + +PERFORMANCE UND BUG-FIXES + +Bilder werden jetzt länger gecached und effizienter geladen, wenn viele Bilder in kurzer Zeit angezeigt werden. + +SONSTIGES + +Bilder werden jetzt clientseitig ge-ratelimited auf 10 Bilder / 10 Sekunden, da bei Commons backendseitig seit kurzem ein strikteres Ratelimiting implentiert wurde. +Leider gibt es noch ein paar Schwierigkeiten, wodurch es leider vermehrt zu Ladefehlern bei Bilder kommt, da die App im backend als Bot klassifiziert wird und nur begrenzte Anfrage-Resourcen zugeteilt bekommt. Ich bin dabei das Problem mit den Verantwortlichen zu klären. + +LIZENZ + +CommonsFinder ist nun unter der GPLv3.0 lizensiert! diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index debafbb..b9f1729 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -1 +1,17 @@ -This version fixes a bug that occurs when loading images whose filenames contain a "+". +NEW FEATURES + +- Files can now be edited (captions, categories, depiction)! +- Search results in the search tab are now sortable by newest/oldest results + +PERFORMANCE AND BUG FIXES + +- Images are now cached for longer and loaded more efficiently when many images are displayed in a short time. + +OTHER + +Images are now client-side rate-limited to 10 images per 10 seconds, as Commons recently implemented stricter rate limiting on the backend. +Unfortunately, there are still a few issues, which are causing an increased number of image loading errors because the app is classified as a bot in the backend and is only allocated limited request resources. I am currently working with the responsible parties to resolve this issue. + +LICENSE + +CommonsFinder is now licensed under the GPLv3.0! From 675737ddfc1fdc28dfce8b8315db89634767e0fe Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Thu, 19 Feb 2026 19:28:10 +0100 Subject: [PATCH 03/13] fix: add sendable to a type --- CommonsAPI/Sources/CommonsAPI/API.swift | 2 +- CommonsFinder/Localizable.xcstrings | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CommonsAPI/Sources/CommonsAPI/API.swift b/CommonsAPI/Sources/CommonsAPI/API.swift index e6de3d3..859935c 100644 --- a/CommonsAPI/Sources/CommonsAPI/API.swift +++ b/CommonsAPI/Sources/CommonsAPI/API.swift @@ -1283,7 +1283,7 @@ LIMIT \(limit) } - public enum PublishingStep: Equatable { + public enum PublishingStep: Equatable, Sendable { // NOTE: uploadDataAndUnstash is one stage combined, because the unstash depends on knowing the filekey case uploadData case unstash(filekey: String) diff --git a/CommonsFinder/Localizable.xcstrings b/CommonsFinder/Localizable.xcstrings index 0a6bfc5..07a12c3 100644 --- a/CommonsFinder/Localizable.xcstrings +++ b/CommonsFinder/Localizable.xcstrings @@ -577,6 +577,7 @@ }, "Console" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { From e3d4790156e78ebf22db1188bf1d5870bda23dcb Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Sun, 22 Feb 2026 13:21:04 +0100 Subject: [PATCH 04/13] fix(FileDetailView): do not start and immediately cancel refresh tags task in some cases --- CommonsFinder/Localizable.xcstrings | 1 - .../Views/FileDetailView/FileDetailView.swift | 17 +++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CommonsFinder/Localizable.xcstrings b/CommonsFinder/Localizable.xcstrings index 07a12c3..0a6bfc5 100644 --- a/CommonsFinder/Localizable.xcstrings +++ b/CommonsFinder/Localizable.xcstrings @@ -577,7 +577,6 @@ }, "Console" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/CommonsFinder/Views/FileDetailView/FileDetailView.swift b/CommonsFinder/Views/FileDetailView/FileDetailView.swift index ab17698..8d92028 100644 --- a/CommonsFinder/Views/FileDetailView/FileDetailView.swift +++ b/CommonsFinder/Views/FileDetailView/FileDetailView.swift @@ -208,28 +208,32 @@ struct FileDetailView: View { logger.error("CAT: Failed to observe MediaFileInfo changes \(error)") } } - .task(priority: .high) { + .task(id: isResolvingTags, priority: .high) { + guard isResolvingTags == false else { return } let timeIntervalSinceLastFetchDate = Date.now.timeIntervalSince(mediaFileInfo.mediaFile.fetchDate) - // logger.info("Time since last fetch: \(timeIntervalSinceLastFetchDate)") if timeIntervalSinceLastFetchDate > 20 { - await refreshFromNetwork() + do { + try await Task.sleep(for: .milliseconds(250)) + await refreshFromNetwork() + } catch {} } } .task(id: tagsHashID, priority: .userInitiated) { + guard resolvedTags.isEmpty else { return } isResolvingTags = true do { logger.info("Resolving Tags...") - let start = Date.now let tags = try await mediaFileInfo.mediaFile.resolveTags(appDatabase: appDatabase) - let tagsWhereFetchedAsync = Date.now.timeIntervalSince(start) > 0.1 - withAnimation(tagsWhereFetchedAsync ? .default : nil) { + withAnimation(.interactiveSpring) { self.resolvedTags = tags logger.info("Resolving Tags finished.") } isResolvingTags = false } catch is CancellationError { logger.error("tags resolve cancelled.") + } catch URLError.cancelled { + logger.error("tags resolve cancelled.") } catch { logger.error("Failed to resolve MediaFile tags: \(error)") isResolvingTags = false @@ -474,6 +478,7 @@ struct FileDetailView: View { } } .animation(.default, value: isResolvingTags) + .animation(.default, value: resolvedTags) } } From 21f9ded838669931cb0b067bae221feaa19f2bfb Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Sun, 22 Feb 2026 14:52:08 +0100 Subject: [PATCH 05/13] feat: sortable and searchable RecentlyViewedMediaView --- .../Database/AppDatabase+Queries.swift | 20 +++- CommonsFinder/Views/Home/HomeView.swift | 2 +- .../RecentlyViewedMediaView.swift | 101 ++++++++++++++---- .../Views/Search/Model/SearchOrder.swift | 2 +- 4 files changed, 98 insertions(+), 27 deletions(-) diff --git a/CommonsFinder/Database/AppDatabase+Queries.swift b/CommonsFinder/Database/AppDatabase+Queries.swift index a58c092..e018f79 100644 --- a/CommonsFinder/Database/AppDatabase+Queries.swift +++ b/CommonsFinder/Database/AppDatabase+Queries.swift @@ -56,17 +56,33 @@ struct AllUploadsRequest: ValueObservationQueryable { /// A @Query request that observes all media items in the database struct AllRecentlyViewedMediaFileRequest: ValueObservationQueryable { + let order: AppDatabase.BasicOrdering + let searchText: String + static var defaultValue: [MediaFileInfo] { [] } func fetch(_ db: Database) throws -> [MediaFileInfo] { + var request = + switch order { + case .asc: MediaFile.including(required: MediaFile.itemInteraction.order(\.lastViewed.asc)) + case .desc: MediaFile.including(required: MediaFile.itemInteraction.order(\.lastViewed.desc)) + } + + if !searchText.isEmpty { + request = request.filter( + sql: "mediaFile.rowid IN (SELECT mediaFile_ft.rowid FROM mediaFile_ft WHERE mediaFile_ft MATCH ?)", + arguments: [FTS5Pattern(matchingAllPrefixesIn: searchText)] + ) + } + return - try MediaFile - .including(required: MediaFile.itemInteraction.order(\.lastViewed.desc)) + try request .asRequest(of: MediaFileInfo.self) .fetchAll(db) } } + /// A @Query request that observes all wiki items in the database struct AllRecentlyViewedWikiItemsRequest: ValueObservationQueryable { static var defaultValue: [CategoryInfo] { [] } diff --git a/CommonsFinder/Views/Home/HomeView.swift b/CommonsFinder/Views/Home/HomeView.swift index 8996baa..1004467 100644 --- a/CommonsFinder/Views/Home/HomeView.swift +++ b/CommonsFinder/Views/Home/HomeView.swift @@ -15,7 +15,7 @@ struct HomeView: View { @Environment(AccountModel.self) private var account @Query(AllDraftsRequest()) private var drafts - @Query(AllRecentlyViewedMediaFileRequest()) private var recentlyViewedFiles + @Query(AllRecentlyViewedMediaFileRequest(order: .desc, searchText: "")) private var recentlyViewedFiles @Query(AllBookmarksFileRequest()) private var bookmarkedFiles @Query(AllRecentlyViewedWikiItemsRequest()) private var recentlyViewedWikiItems @Query(AllBookmarksWikiItemRequest()) private var bookmarkedCategories diff --git a/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift b/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift index b04fe7c..4cad841 100644 --- a/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift +++ b/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift @@ -5,55 +5,110 @@ // Created by Tom Brewe on 21.05.25. // +import Algorithms import GRDB import SwiftUI import os.log struct RecentlyViewedMediaView: View { - @State private var mediaFileInfos: [MediaFileInfo]? + @State private var mediaFileInfos: [MediaFileInfo] = [] @State private var observationTask: Task? + @State private var order: SearchOrder = .newest + @State private var searchText = "" + @State private var isSearchPresented = false @Environment(\.appDatabase) private var appDatabase var body: some View { ScrollView(.vertical) { - if let mediaFileInfos { - if mediaFileInfos.isEmpty { - ContentUnavailableView( - "No recently viewed images", - image: "photo.stack", - description: Text("You will find a history of your previously viewed images here.") - ) - } else { - LazyVStack(spacing: 20) { - ForEach(mediaFileInfos) { mediaFileInfo in - MediaFileListItem(mediaFileInfo: mediaFileInfo) - } + if mediaFileInfos.isEmpty, searchText.isEmpty { + ContentUnavailableView( + "No recently viewed images", + systemImage: "photo.stack", + description: Text("You will find a history of your previously viewed images here.") + ) + } else if mediaFileInfos.isEmpty, !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + LazyVStack(spacing: 20) { + ForEach(mediaFileInfos) { mediaFileInfo in + MediaFileListItem(mediaFileInfo: mediaFileInfo) } - .compositingGroup() - .scenePadding() - .safeAreaPadding(.trailing) - .shadow(color: .black.opacity(0.15), radius: 10) } + .compositingGroup() + .scenePadding() + .safeAreaPadding(.trailing) + .shadow(color: .black.opacity(0.15), radius: 10) } } .navigationTitle("Recently Viewed") .navigationBarTitleDisplayMode(.inline) - .task { - guard observationTask == nil else { return } + .searchable(text: $searchText, isPresented: $isSearchPresented) + .searchPresentationToolbarBehavior(.avoidHidingContent) + .toolbar { + ToolbarItem { + SearchOrderButton(searchOrder: $order, possibleCases: [.newest, .oldest]) + } + ToolbarItem { + Button("Search", systemImage: "magnifyingglass") { + isSearchPresented = true + } + } + } + .onChange(of: (searchText + order.rawValue), initial: true) { oldValue, newValue in + guard observationTask == nil || (newValue != oldValue) else { + return + } + logger.debug("observation change: \(newValue) !=? \(oldValue)") observationTask?.cancel() observationTask = Task { + let observation = ValueObservation.tracking { [order, searchText] db in + try AllRecentlyViewedMediaFileRequest( + order: order == .newest ? .desc : .asc, + searchText: searchText + ) + .fetch(db) + } + + var orginalOrderedIDs: [MediaFileInfo.ID] = [] + do { - mediaFileInfos = try appDatabase.fetchRecentlyViewedMediaFileInfos(order: .desc) + for try await refreshedMediaFileInfos in observation.values(in: appDatabase.reader) { + try Task.checkCancellation() + // IMPORTANT: we want retain the orginal order, to not cause annoying + // layout changes when the user taps on an item and returns here. + if orginalOrderedIDs.isEmpty { + orginalOrderedIDs = refreshedMediaFileInfos.map(\.id) + } + + var refreshedGrouped = refreshedMediaFileInfos.grouped(by: \.id) + + mediaFileInfos = orginalOrderedIDs.compactMap { id in + if let match = refreshedGrouped[id]?.first { + refreshedGrouped.removeValue(forKey: id) + return match + } else { + return nil + } + } + + let umatchedRefreshed = refreshedGrouped.values + assert(umatchedRefreshed.isEmpty) + } } catch { - logger.error("Failed to observe media files \(error)") + } } } } + } -#Preview { - RecentlyViewedMediaView() +#Preview(traits: .previewEnvironment) { + Color.clear.fullScreenCover(isPresented: .constant(true)) { + NavigationStack { + RecentlyViewedMediaView() + } + } } diff --git a/CommonsFinder/Views/Search/Model/SearchOrder.swift b/CommonsFinder/Views/Search/Model/SearchOrder.swift index 1208b3f..d6e8e13 100644 --- a/CommonsFinder/Views/Search/Model/SearchOrder.swift +++ b/CommonsFinder/Views/Search/Model/SearchOrder.swift @@ -7,7 +7,7 @@ import Foundation -enum SearchOrder: Hashable, Equatable, CaseIterable, CustomLocalizedStringResourceConvertible { +nonisolated enum SearchOrder: String, Hashable, Equatable, CaseIterable, CustomLocalizedStringResourceConvertible, Sendable { var localizedStringResource: LocalizedStringResource { switch self { case .relevance: LocalizedStringResource(stringLiteral: "Relevant") From e4b51bd7facb5288f578cec6564d767a34661413 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Sun, 22 Feb 2026 15:43:54 +0100 Subject: [PATCH 06/13] feat: sortable and searchable BookmarkedFilesView - and simplify the DB fetch and observe for RecentlyViewedMediaView.swift as well --- .../Database/AppDatabase+Queries.swift | 42 ++++++++-- CommonsFinder/Database/AppDatabase.swift | 17 ++-- .../Views/Bookmarks/BookmarkedFilesView.swift | 79 +++++++++++++++++-- CommonsFinder/Views/Home/HomeView.swift | 2 +- .../RecentlyViewedMediaView.swift | 53 ++++++------- 5 files changed, 141 insertions(+), 52 deletions(-) diff --git a/CommonsFinder/Database/AppDatabase+Queries.swift b/CommonsFinder/Database/AppDatabase+Queries.swift index e018f79..ceb6ad2 100644 --- a/CommonsFinder/Database/AppDatabase+Queries.swift +++ b/CommonsFinder/Database/AppDatabase+Queries.swift @@ -54,6 +54,20 @@ struct AllUploadsRequest: ValueObservationQueryable { } } + +struct MediaFilesByIDRequest: ValueObservationQueryable { + let ids: [MediaFile.ID] + static var defaultValue: [MediaFileInfo] { [] } + + func fetch(_ db: Database) throws -> [MediaFileInfo] { + try MediaFile + .filter(ids: ids) + .including(optional: MediaFile.itemInteraction) + .asRequest(of: MediaFileInfo.self) + .fetchAll(db) + } +} + /// A @Query request that observes all media items in the database struct AllRecentlyViewedMediaFileRequest: ValueObservationQueryable { let order: AppDatabase.BasicOrdering @@ -98,15 +112,31 @@ struct AllRecentlyViewedWikiItemsRequest: ValueObservationQueryable { /// A @Query request that observes all bookmarked media items in the database struct AllBookmarksFileRequest: ValueObservationQueryable { static var defaultValue: [MediaFileInfo] { [] } + let order: AppDatabase.BasicOrdering + let searchText: String + func fetch(_ db: Database) throws -> [MediaFileInfo] { - try MediaFile - .including( - required: MediaFile - .itemInteraction - .filter { $0.bookmarked != nil } - .order(\.bookmarked.desc) + var request = + switch order { + case .asc: + MediaFile.including( + required: MediaFile.itemInteraction.filter { $0.bookmarked != nil }.order(\.bookmarked.asc) + ) + case .desc: + MediaFile.including( + required: MediaFile.itemInteraction.filter { $0.bookmarked != nil }.order(\.bookmarked.desc)) + } + + if !searchText.isEmpty { + request = request.filter( + sql: "mediaFile.rowid IN (SELECT mediaFile_ft.rowid FROM mediaFile_ft WHERE mediaFile_ft MATCH ?)", + arguments: [FTS5Pattern(matchingAllPrefixesIn: searchText)] ) + } + + return + try request .asRequest(of: MediaFileInfo.self) .fetchAll(db) } diff --git a/CommonsFinder/Database/AppDatabase.swift b/CommonsFinder/Database/AppDatabase.swift index 4a98dc2..a78968c 100644 --- a/CommonsFinder/Database/AppDatabase.swift +++ b/CommonsFinder/Database/AppDatabase.swift @@ -814,18 +814,15 @@ nonisolated extension AppDatabase { case desc } - func fetchRecentlyViewedMediaFileInfos(order: BasicOrdering) throws -> [MediaFileInfo] { + func fetchRecentlyViewedMediaFileInfos(order: BasicOrdering, searchText: String) throws -> [MediaFileInfo] { try dbWriter.read { db in - let request = - switch order { - case .asc: MediaFile.including(required: MediaFile.itemInteraction.order(\.lastViewed.asc)) - case .desc: MediaFile.including(required: MediaFile.itemInteraction.order(\.lastViewed.desc)) - } + try AllRecentlyViewedMediaFileRequest(order: order, searchText: searchText).fetch(db) + } + } - return - try request - .asRequest(of: MediaFileInfo.self) - .fetchAll(db) + func fetchBookmardMediaFileInfos(order: BasicOrdering, searchText: String) throws -> [MediaFileInfo] { + try dbWriter.read { db in + try AllBookmarksFileRequest(order: order, searchText: searchText).fetch(db) } } diff --git a/CommonsFinder/Views/Bookmarks/BookmarkedFilesView.swift b/CommonsFinder/Views/Bookmarks/BookmarkedFilesView.swift index be88e6a..e7e6e37 100644 --- a/CommonsFinder/Views/Bookmarks/BookmarkedFilesView.swift +++ b/CommonsFinder/Views/Bookmarks/BookmarkedFilesView.swift @@ -5,18 +5,29 @@ // Created by Tom Brewe on 17.06.25. // +import Algorithms +import GRDB import GRDBQuery import SwiftUI import os.log struct BookmarkedFilesView: View { - @Query(AllBookmarksFileRequest()) private var mediaFileInfos + @State private var mediaFileInfos: [MediaFileInfo] = [] + @State private var observationTask: Task? + @State private var order: SearchOrder = .newest + @State private var searchText = "" + @State private var isSearchPresented = false + + @Environment(\.appDatabase) private var appDatabase var body: some View { - if mediaFileInfos.isEmpty { - BookmarksUnavailableView() - } else { - ScrollView(.vertical) { + + ScrollView(.vertical) { + if mediaFileInfos.isEmpty, searchText.isEmpty { + BookmarksUnavailableView() + } else if mediaFileInfos.isEmpty, !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { LazyVStack(spacing: 20) { ForEach(mediaFileInfos) { mediaFileInfo in MediaFileListItem(mediaFileInfo: mediaFileInfo) @@ -26,12 +37,66 @@ struct BookmarkedFilesView: View { .scenePadding() .safeAreaPadding(.trailing) .shadow(color: .black.opacity(0.15), radius: 10) + } + } + .navigationTitle("Bookmarks") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, isPresented: $isSearchPresented) + .searchPresentationToolbarBehavior(.avoidHidingContent) + .toolbar { + ToolbarItem { + SearchOrderButton(searchOrder: $order, possibleCases: [.newest, .oldest]) + } + ToolbarItem { + Button("Search", systemImage: "magnifyingglass") { + isSearchPresented = true + } + } + } + .onChange(of: (searchText + order.rawValue), initial: true) { oldValue, newValue in + guard observationTask == nil || (newValue != oldValue) else { + return + } + let originalIDs: [String] + + do { + let initialFetchedResults = try appDatabase.fetchBookmardMediaFileInfos( + order: order == .newest ? .desc : .asc, + searchText: searchText + ) + + originalIDs = initialFetchedResults.map(\.id) + mediaFileInfos = initialFetchedResults + } catch { + logger.error("Failed to fetch reciently viewed media \(error)") + return + } + + // NOTE: The observation is mainly to observe for bookmark toggling. + // IMPORTANT: we want retain the orginal order, and not cause layout shifts if the original order would changes. + // thats why we fetch by IDs in the observation, not the original request itself! + observationTask?.cancel() + observationTask = Task { + let observation = ValueObservation.tracking { [originalIDs] db in + try MediaFilesByIDRequest(ids: originalIDs).fetch(db) + } + + do { + for try await refreshedMediaFileInfos in observation.values(in: appDatabase.reader) { + try Task.checkCancellation() + let grouped = refreshedMediaFileInfos.grouped(by: \.id) + mediaFileInfos = originalIDs.compactMap { id in + grouped[id]?.first + } + } + } catch { + logger.error("Failed to fetch reciently viewed media \(error)") + } } - .navigationTitle("Bookmarks") - .navigationBarTitleDisplayMode(.inline) } + } } diff --git a/CommonsFinder/Views/Home/HomeView.swift b/CommonsFinder/Views/Home/HomeView.swift index 1004467..90edd03 100644 --- a/CommonsFinder/Views/Home/HomeView.swift +++ b/CommonsFinder/Views/Home/HomeView.swift @@ -16,7 +16,7 @@ struct HomeView: View { @Query(AllDraftsRequest()) private var drafts @Query(AllRecentlyViewedMediaFileRequest(order: .desc, searchText: "")) private var recentlyViewedFiles - @Query(AllBookmarksFileRequest()) private var bookmarkedFiles + @Query(AllBookmarksFileRequest(order: .desc, searchText: "")) private var bookmarkedFiles @Query(AllRecentlyViewedWikiItemsRequest()) private var recentlyViewedWikiItems @Query(AllBookmarksWikiItemRequest()) private var bookmarkedCategories diff --git a/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift b/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift index 4cad841..c3d0c28 100644 --- a/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift +++ b/CommonsFinder/Views/RecentlyViewed/RecentlyViewedMediaView.swift @@ -59,45 +59,42 @@ struct RecentlyViewedMediaView: View { guard observationTask == nil || (newValue != oldValue) else { return } - logger.debug("observation change: \(newValue) !=? \(oldValue)") + let originalIDs: [String] + + do { + let initialFetchedResults = try appDatabase.fetchRecentlyViewedMediaFileInfos( + order: order == .newest ? .desc : .asc, + searchText: searchText + ) + + originalIDs = initialFetchedResults.map(\.id) + mediaFileInfos = initialFetchedResults + } catch { + logger.error("Failed to fetch reciently viewed media \(error)") + return + + } + + // NOTE: The observation is mainly to observe for bookmark toggling. + // IMPORTANT: we want retain the orginal order, and not cause layout shifts if the original order would changes. + // thats why we fetch by IDs in the observation, not the original request itself! observationTask?.cancel() observationTask = Task { - let observation = ValueObservation.tracking { [order, searchText] db in - try AllRecentlyViewedMediaFileRequest( - order: order == .newest ? .desc : .asc, - searchText: searchText - ) - .fetch(db) + let observation = ValueObservation.tracking { [originalIDs] db in + try MediaFilesByIDRequest(ids: originalIDs).fetch(db) } - var orginalOrderedIDs: [MediaFileInfo.ID] = [] - do { for try await refreshedMediaFileInfos in observation.values(in: appDatabase.reader) { try Task.checkCancellation() - // IMPORTANT: we want retain the orginal order, to not cause annoying - // layout changes when the user taps on an item and returns here. - if orginalOrderedIDs.isEmpty { - orginalOrderedIDs = refreshedMediaFileInfos.map(\.id) - } - - var refreshedGrouped = refreshedMediaFileInfos.grouped(by: \.id) - - mediaFileInfos = orginalOrderedIDs.compactMap { id in - if let match = refreshedGrouped[id]?.first { - refreshedGrouped.removeValue(forKey: id) - return match - } else { - return nil - } + let grouped = refreshedMediaFileInfos.grouped(by: \.id) + mediaFileInfos = originalIDs.compactMap { id in + grouped[id]?.first } - - let umatchedRefreshed = refreshedGrouped.values - assert(umatchedRefreshed.isEmpty) } } catch { - + logger.error("Failed to fetch reciently viewed media \(error)") } } } From 26b9eaf7c0eeeb6466ed04e4df4d35d0c2785258 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Sun, 22 Feb 2026 17:29:04 +0100 Subject: [PATCH 07/13] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5cd19e4..1e5a02f 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The idea for "CommonsFinder" came from combining the [view*finder*](https://en.w The best way to curently help, is by using the TestFlight releases and especially reporting crashes (should they occur) as well as other experience breaking issues: https://testflight.apple.com/join/15KtE2Mn -MRs authored by LLM agent tools may be closed without further comment if they are non-trivial or implementing features or bugs that have not been discussed and reported before. +MRs authored by LLM agent tools may be closed without further comment if they are non-trivial, especially if they implementing features or bugs that have not been discussed and reported before. ## Funding and Donations From 4c4ea270a05162ee99330178f29ba95778a549b2 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Tue, 24 Feb 2026 09:12:39 +0100 Subject: [PATCH 08/13] Typo README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e5a02f..f0affd1 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The idea for "CommonsFinder" came from combining the [view*finder*](https://en.w The best way to curently help, is by using the TestFlight releases and especially reporting crashes (should they occur) as well as other experience breaking issues: https://testflight.apple.com/join/15KtE2Mn -MRs authored by LLM agent tools may be closed without further comment if they are non-trivial, especially if they implementing features or bugs that have not been discussed and reported before. +MRs authored by LLM agent tools may be closed without further comment if they are non-trivial, especially if they are implementing features or bugs that have not been discussed and reported before. ## Funding and Donations From 4e31dd57b45b54674e75a61817b92777a0b23391 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Tue, 24 Feb 2026 13:48:51 +0100 Subject: [PATCH 09/13] fix: fixes editing a file not properly refetching the file for display --- CommonsFinder/DataFetching/DataAccess.swift | 17 ++++++ .../Views/FileDetailView/FileDetailView.swift | 57 +++++-------------- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/CommonsFinder/DataFetching/DataAccess.swift b/CommonsFinder/DataFetching/DataAccess.swift index 05f7648..f8e3006 100644 --- a/CommonsFinder/DataFetching/DataAccess.swift +++ b/CommonsFinder/DataFetching/DataAccess.swift @@ -15,6 +15,23 @@ import os.log // To be refined with more DB-first searches and fetchDate comparisons. (like `fetchCombinedTagsFromDatabaseOrAPI`) enum DataAccess { + static func refreshMediaFileFromNetwork(id: MediaFile.ID, appDatabase: AppDatabase) async { + do { + guard + let result = try await Networking.shared.api + .fetchFullFileMetadata(.pageids([id])).first + else { + return + } + + let refreshedMediaFile = MediaFile(apiFileMetadata: result) + // NOTE: upserting here, will propagate the change in the DB observation further down. + try appDatabase.upsert([refreshedMediaFile]) + } catch { + logger.error("Failed to refresh media file \(error)") + } + } + /// Will cache the result and return an up-to-date CategoryInfo. (edge case: It may have a different ID as a result of a redirect) static func refreshCategoryInfoFromAPI(categoryInfo: CategoryInfo, appDatabase: AppDatabase) async throws -> Category? { var wikidataIDs: [String] = [] diff --git a/CommonsFinder/Views/FileDetailView/FileDetailView.swift b/CommonsFinder/Views/FileDetailView/FileDetailView.swift index 8d92028..20c3838 100644 --- a/CommonsFinder/Views/FileDetailView/FileDetailView.swift +++ b/CommonsFinder/Views/FileDetailView/FileDetailView.swift @@ -79,36 +79,6 @@ struct FileDetailView: View { editingStatus?.error } - @concurrent - private func refreshFromNetwork() async { - do { - guard - let result = try await Networking.shared.api - .fetchFullFileMetadata(.pageids([mediaFileInfo.mediaFile.id])).first - else { - return - } - - let refreshedMediaFile = MediaFile(apiFileMetadata: result) - try await appDatabase.upsert([refreshedMediaFile]) - - guard let refreshedMediaFileInfo = try await appDatabase.fetchMediaFileInfo(id: refreshedMediaFile.id) else { - assertionFailure("MediaFileInfo nil although we just saved the underlying mediaFile.") - return - } - - let refreshedTags = try await refreshedMediaFile.resolveTags(appDatabase: appDatabase) - - await MainActor.run { - self.updatedMediaFileInfo = refreshedMediaFileInfo - self.resolvedTags = refreshedTags - } - - } catch { - logger.error("Failed to refresh media file \(error)") - } - } - var body: some View { lazy var languageIdentifier = locale.wikiLanguageCodeIdentifier let navTitle = mediaFileInfo.mediaFile.localizedDisplayCaption ?? mediaFileInfo.mediaFile.displayName @@ -201,25 +171,13 @@ struct FileDetailView: View { for try await updatedMediaFileInfo in observation.values(in: appDatabase.reader) { try Task.checkCancellation() - self.updatedMediaFileInfo = updatedMediaFileInfo } } catch { logger.error("CAT: Failed to observe MediaFileInfo changes \(error)") } } - .task(id: isResolvingTags, priority: .high) { - guard isResolvingTags == false else { return } - let timeIntervalSinceLastFetchDate = Date.now.timeIntervalSince(mediaFileInfo.mediaFile.fetchDate) - if timeIntervalSinceLastFetchDate > 20 { - do { - try await Task.sleep(for: .milliseconds(250)) - await refreshFromNetwork() - } catch {} - } - } - .task(id: tagsHashID, priority: .userInitiated) { - guard resolvedTags.isEmpty else { return } + .task(id: mediaFileInfo.mediaFile.fetchDate, priority: .userInitiated) { isResolvingTags = true do { @@ -238,6 +196,18 @@ struct FileDetailView: View { logger.error("Failed to resolve MediaFile tags: \(error)") isResolvingTags = false } + + // After resolving tags, if the file hasn't been refreshed from network in a while + // do it now + + let timeIntervalSinceLastFetchDate = Date.now.timeIntervalSince(mediaFileInfo.mediaFile.fetchDate) + if timeIntervalSinceLastFetchDate > 20 { + do { + try await Task.sleep(for: .milliseconds(250)) + // NOTE: changes from refresh will propagate into the DB observation further above. + await DataAccess.refreshMediaFileFromNetwork(id: mediaFileInfo.id, appDatabase: appDatabase) + } catch {} + } } .onDisappear { saveFileToLastViewed() @@ -479,7 +449,6 @@ struct FileDetailView: View { } .animation(.default, value: isResolvingTags) .animation(.default, value: resolvedTags) - } } From 7c58a2a15d5ed017c6b9e1d40dcae6d3abed477b Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Thu, 26 Feb 2026 18:36:01 +0100 Subject: [PATCH 10/13] perf: prefetch file tags after paginating and fix some caching behavior. This eliminates tag-resolving network requests each time when a file is opened. --- CommonsFinder.xcodeproj/project.pbxproj | 1 - CommonsFinder/DataFetching/DataAccess.swift | 78 +++++++++++++++---- .../Model/Extensions/MediaFileInfo+Tags.swift | 46 ----------- .../PaginatableMediaFiles.swift | 3 + .../Views/FileDetailView/FileDetailView.swift | 7 +- .../Views/FileEditView/FileEditView.swift | 7 +- 6 files changed, 78 insertions(+), 64 deletions(-) delete mode 100644 CommonsFinder/Database/Model/Extensions/MediaFileInfo+Tags.swift diff --git a/CommonsFinder.xcodeproj/project.pbxproj b/CommonsFinder.xcodeproj/project.pbxproj index f73ffa7..1cad936 100644 --- a/CommonsFinder.xcodeproj/project.pbxproj +++ b/CommonsFinder.xcodeproj/project.pbxproj @@ -134,7 +134,6 @@ "Database/Model/Extensions/MediaFileDraft+DebugDraft.swift", "Database/Model/Extensions/MediaFileInfo+ImageRequest.swift", "Database/Model/Extensions/MediaFileInfo+sortedByLastViewed.swift", - "Database/Model/Extensions/MediaFileInfo+Tags.swift", "Database/Model/Extensions/WikidataItem+InitFromAPI.swift", "Database/Model/Extensions/WikidataItem+Thumbnail.swift", Database/Model/ItemInteraction.swift, diff --git a/CommonsFinder/DataFetching/DataAccess.swift b/CommonsFinder/DataFetching/DataAccess.swift index f8e3006..0a8c093 100644 --- a/CommonsFinder/DataFetching/DataAccess.swift +++ b/CommonsFinder/DataFetching/DataAccess.swift @@ -14,7 +14,6 @@ import os.log /// Provides data access functions to the API or DB // To be refined with more DB-first searches and fetchDate comparisons. (like `fetchCombinedTagsFromDatabaseOrAPI`) enum DataAccess { - static func refreshMediaFileFromNetwork(id: MediaFile.ID, appDatabase: AppDatabase) async { do { guard @@ -54,7 +53,7 @@ enum DataAccess { return results.fetchedCategories.first } - /// resolves categories based on commons categories and depict items (eg. from a MediaFile) + /// resolves categories based on commons categories and depict items (eg. from a MediaFile), always caches API results /// Commons categories that are not linked with a wikidata item will still be returned as Categories. /// will return redirected (merged) items instead of original ones! /// Order of returned results: @@ -71,26 +70,35 @@ enum DataAccess { if forceNetworkRefresh { cachedCategories = [] } else { - cachedCategories = (try? appDatabase.fetchCategoryInfos(wikidataIDs: wikidataIDs, resolveRedirections: true))?.compactMap(\.base) ?? [] + let cachedByWikidataID = (try? appDatabase.fetchCategoryInfos(wikidataIDs: wikidataIDs, resolveRedirections: true))?.compactMap(\.base) ?? [] + let cachedByCommonsCategory = (try? appDatabase.fetchCategoryInfos(commonsCategories: commonsCategories))?.compactMap(\.base) ?? [] + cachedCategories = (cachedByWikidataID + cachedByCommonsCategory).uniqued(on: { $0.wikidataId ?? $0.commonsCategory }) } let cachedIDs = cachedCategories.compactMap(\.wikidataId) + let cachedCommonsCategories = cachedCategories.compactMap(\.commonsCategory) let missingIDs = Set(wikidataIDs).subtracting(cachedIDs) + let missingCommonsCategories = Set(commonsCategories).subtracting(cachedCommonsCategories) + var fetchResult: CategoryFetchResult? - let fetchResult = try await fetchWikidataBackedCategoriesFromAPI( - wikidataIDs: Array(missingIDs), - commonsCategories: commonsCategories, - // if we refresh from network, we want to cache the results - shouldCache: forceNetworkRefresh, - appDatabase: appDatabase - ) - let fetchedAndCachedCombined = cachedCategories + fetchResult.fetchedCategories + if !missingIDs.isEmpty || !missingCommonsCategories.isEmpty { + fetchResult = try await fetchWikidataBackedCategoriesFromAPI( + wikidataIDs: Array(missingIDs), + commonsCategories: commonsCategories, + shouldCache: true, + appDatabase: appDatabase + ) + } + + let fetchedCategories = fetchResult?.fetchedCategories ?? [] + + let fetchedAndCachedCombined = cachedCategories + fetchedCategories let groupedByWikidataID = fetchedAndCachedCombined.grouped(by: \.wikidataId) let groupedByCommonsCategory = fetchedAndCachedCombined.grouped(by: \.commonsCategory) let sortedByWikidataID: [Category] = wikidataIDs.compactMap { id in - let redirectID = fetchResult.redirectedIDs[id] + let redirectID = fetchResult?.redirectedIDs[id] return if let category = groupedByWikidataID[id]?.first ?? groupedByWikidataID[redirectID]?.first { category } else { @@ -115,9 +123,13 @@ enum DataAccess { let resultCategories = (sortedByWikidataID + sortedByCommonsCategory + sortedPureCommonsCategories) .uniqued(on: { $0.wikidataId ?? $0.commonsCategory }) + // NOTE: Some categories are already cached when calling fetchWikidataBackedCategoriesFromAPI + // but pure commons categories are, not. So to be complete, we upsert all final results here. + try appDatabase.upsert(resultCategories) + return .init( fetchedCategories: resultCategories, - redirectedIDs: fetchResult.redirectedIDs + redirectedIDs: fetchResult?.redirectedIDs ?? [:] ) } @@ -265,4 +277,44 @@ enum DataAccess { return .init(fetchedCategories: resultItems, redirectedIDs: resultRedirections) } + + /// resolves Tags based on commons categories and depict items in MediaFile + /// will return redirected (merged) items instead of original ones! + @discardableResult + static func resolveTags(of mediaFiles: [MediaFile], appDatabase: AppDatabase, forceNetworkRefresh: Bool = false) async throws -> [TagItem] { + + let depictWikdataIDs: [String] = + mediaFiles + .flatMap(\.statements) + .filter(\.isDepicts) + .compactMap(\.mainItem?.id) + + let commonsCategories = mediaFiles.flatMap(\.categories) + + + let result = try await DataAccess.fetchCombinedCategoriesFromDatabaseOrAPI( + wikidataIDs: depictWikdataIDs, + commonsCategories: commonsCategories, + forceNetworkRefresh: forceNetworkRefresh, + appDatabase: appDatabase + ) + + let depictIDsWithResolvedRedirects: [String] = depictWikdataIDs.map { depictID in + result.redirectedIDs[depictID] ?? depictID + } + + let categoriesSet = Set(commonsCategories) + let depictIDSet = Set(depictIDsWithResolvedRedirects) + + return result.fetchedCategories.map { + var picked: Set = [] + if let wikidataID = $0.wikidataId, depictIDSet.contains(wikidataID) { + picked.insert(.depict) + } + if let commonsCategory = $0.commonsCategory, categoriesSet.contains(commonsCategory) { + picked.insert(.category) + } + return .init($0, pickedUsages: picked) + } + } } diff --git a/CommonsFinder/Database/Model/Extensions/MediaFileInfo+Tags.swift b/CommonsFinder/Database/Model/Extensions/MediaFileInfo+Tags.swift deleted file mode 100644 index bfa828a..0000000 --- a/CommonsFinder/Database/Model/Extensions/MediaFileInfo+Tags.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// MediaFileInfo+Tags.swift -// CommonsFinder -// -// Created by Tom Brewe on 20.04.25. -// - -import Algorithms -import Foundation - -extension MediaFile { - - /// resolves Tags based on commons categories and depict items in MediaFile - /// will return redirected (merged) items instead of original ones! - func resolveTags(appDatabase: AppDatabase, forceNetworkRefresh: Bool = false) async throws -> [TagItem] { - let depictWikdataIDs: [String] = - statements - .filter(\.isDepicts) - .compactMap(\.mainItem?.id) - - - let result = try await DataAccess.fetchCombinedCategoriesFromDatabaseOrAPI( - wikidataIDs: depictWikdataIDs, - commonsCategories: categories, - appDatabase: appDatabase - ) - - let depictIDsWithResolvedRedirects: [String] = depictWikdataIDs.map { depictID in - result.redirectedIDs[depictID] ?? depictID - } - - let categoriesSet = Set(categories) - let depictIDSet = Set(depictIDsWithResolvedRedirects) - - return result.fetchedCategories.map { - var picked: Set = [] - if let wikidataID = $0.wikidataId, depictIDSet.contains(wikidataID) { - picked.insert(.depict) - } - if let commonsCategory = $0.commonsCategory, categoriesSet.contains(commonsCategory) { - picked.insert(.category) - } - return .init($0, pickedUsages: picked) - } - } -} diff --git a/CommonsFinder/Observable Models/PaginatableMediaFiles.swift b/CommonsFinder/Observable Models/PaginatableMediaFiles.swift index 0e3c7fe..40644c8 100644 --- a/CommonsFinder/Observable Models/PaginatableMediaFiles.swift +++ b/CommonsFinder/Observable Models/PaginatableMediaFiles.swift @@ -211,6 +211,9 @@ private enum PaginationFileIdentifierType { // observe the DB and augment `mediaFileInfos`. observeDatabase() + // after the essential fetches, do an optional resolving of tags + // this effectively caches the tags and they don't have to be network-fetched individually when opening a file. + _ = try? await DataAccess.resolveTags(of: fetchedMediaFiles, appDatabase: appDatabase) } catch { logger.error("Failed to paginate \(error)") status = .error diff --git a/CommonsFinder/Views/FileDetailView/FileDetailView.swift b/CommonsFinder/Views/FileDetailView/FileDetailView.swift index 20c3838..606b300 100644 --- a/CommonsFinder/Views/FileDetailView/FileDetailView.swift +++ b/CommonsFinder/Views/FileDetailView/FileDetailView.swift @@ -182,7 +182,8 @@ struct FileDetailView: View { do { logger.info("Resolving Tags...") - let tags = try await mediaFileInfo.mediaFile.resolveTags(appDatabase: appDatabase) + let tags = try await DataAccess.resolveTags(of: [mediaFileInfo.mediaFile], appDatabase: appDatabase) + withAnimation(.interactiveSpring) { self.resolvedTags = tags logger.info("Resolving Tags finished.") @@ -197,11 +198,11 @@ struct FileDetailView: View { isResolvingTags = false } - // After resolving tags, if the file hasn't been refreshed from network in a while + // After resolving tags, if the file hasn't been refreshed from network in a while (2 minutes) // do it now let timeIntervalSinceLastFetchDate = Date.now.timeIntervalSince(mediaFileInfo.mediaFile.fetchDate) - if timeIntervalSinceLastFetchDate > 20 { + if timeIntervalSinceLastFetchDate > (2 * 60) { do { try await Task.sleep(for: .milliseconds(250)) // NOTE: changes from refresh will propagate into the DB observation further above. diff --git a/CommonsFinder/Views/FileEditView/FileEditView.swift b/CommonsFinder/Views/FileEditView/FileEditView.swift index 4dc89c4..51c25cf 100644 --- a/CommonsFinder/Views/FileEditView/FileEditView.swift +++ b/CommonsFinder/Views/FileEditView/FileEditView.swift @@ -43,7 +43,12 @@ import os.log self.referenceMediaFileInfo = mediaFileInfo self.captions = mediaFileInfo.mediaFile.captions - let resolvedTags = try await mediaFileInfo.mediaFile.resolveTags(appDatabase: appDatabase, forceNetworkRefresh: true) + let resolvedTags = try await DataAccess.resolveTags( + of: [mediaFileInfo.mediaFile], + appDatabase: appDatabase, + forceNetworkRefresh: true + ) + self.referenceTags = resolvedTags self.tags = resolvedTags } From 682b54e5ef2a162e5c0d16b4d42594ba68906eb0 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Wed, 11 Mar 2026 12:50:29 +0100 Subject: [PATCH 11/13] perf: prefetch file tags after paginating and fix some caching behavior. This eliminates tag-resolving network requests each time when a file is opened. + also prepare multi-file upload feature --- CommonsFinder.xcodeproj/project.pbxproj | 4 +- CommonsFinder/ContentView.swift | 2 +- CommonsFinder/Localizable.xcstrings | 3 + .../Observable Models/Navigation.swift | 2 +- .../FileCreateView/DraftSheetModifer.swift | 134 +++++++++ .../Views/FileCreateView/FileCreateView.swift | 277 ------------------ ...eViewModel.swift => FileImportModel.swift} | 4 +- 7 files changed, 143 insertions(+), 283 deletions(-) create mode 100644 CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift delete mode 100644 CommonsFinder/Views/FileCreateView/FileCreateView.swift rename CommonsFinder/Views/FileCreateView/Model/{FileCreateViewModel.swift => FileImportModel.swift} (98%) diff --git a/CommonsFinder.xcodeproj/project.pbxproj b/CommonsFinder.xcodeproj/project.pbxproj index 1cad936..d87d46b 100644 --- a/CommonsFinder.xcodeproj/project.pbxproj +++ b/CommonsFinder.xcodeproj/project.pbxproj @@ -224,10 +224,10 @@ "Views/Extensions/MediaFile+localizedDisplayCaption.swift", "Views/Extensions/MediaFileDraftModel+ImageRequest.swift", "Views/Extensions/UserDefaults+accessors.swift", - Views/FileCreateView/FileCreateView.swift, + Views/FileCreateView/DraftSheetModifer.swift, Views/FileCreateView/FilenameTip.swift, Views/FileCreateView/LicensePicker.swift, - Views/FileCreateView/Model/FileCreateViewModel.swift, + Views/FileCreateView/Model/FileImportModel.swift, Views/FileCreateView/Model/FileItem.swift, Views/FileCreateView/Model/MediaFileDraftModel.swift, Views/FileCreateView/SingleImageDraftView.swift, diff --git a/CommonsFinder/ContentView.swift b/CommonsFinder/ContentView.swift index 401d44a..432a4ba 100644 --- a/CommonsFinder/ContentView.swift +++ b/CommonsFinder/ContentView.swift @@ -70,7 +70,7 @@ struct ContentView: View { // FileCreateView(appDatabase: appDatabase, newDraftOptions: options) // } // } - .modifier(DraftSheetModifer(model: $navigation.isEditingDraft)) + .modifier(DraftSheetModifer(importModel: $navigation.isEditingDraft)) .onOpenURL(perform: handleURL) .onContinueUserActivity(NSUserActivityTypeLiveActivity) { userActivity in guard let url = userActivity.webpageURL else { return } diff --git a/CommonsFinder/Localizable.xcstrings b/CommonsFinder/Localizable.xcstrings index 0a6bfc5..684a293 100644 --- a/CommonsFinder/Localizable.xcstrings +++ b/CommonsFinder/Localizable.xcstrings @@ -1367,6 +1367,9 @@ } } } + }, + "Multiple files" : { + }, "Nearby Locations" : { diff --git a/CommonsFinder/Observable Models/Navigation.swift b/CommonsFinder/Observable Models/Navigation.swift index 461d52e..b393673 100644 --- a/CommonsFinder/Observable Models/Navigation.swift +++ b/CommonsFinder/Observable Models/Navigation.swift @@ -73,7 +73,7 @@ import os.log } // var isViewingFileSheetOpen: MediaFile.ID? - var isEditingDraft: FileCreateViewModel? + var isEditingDraft: FileImportModel? var isAuthSheetOpen: AuthNavigationDestination? enum DraftSheetNavItem: Identifiable, Equatable { diff --git a/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift b/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift new file mode 100644 index 0000000..c1c5962 --- /dev/null +++ b/CommonsFinder/Views/FileCreateView/DraftSheetModifer.swift @@ -0,0 +1,134 @@ +// +// DraftSheetModifer.swift +// CommonsFinder +// +// Created by Tom Brewe on 08.10.24. +// + +import Nuke +import NukeUI +import OrderedCollections +import PhotosUI +import SwiftUI +import os.log + +struct DraftSheetModifer: ViewModifier { + @Binding var importModel: FileImportModel? + + @State private var draftedFileModels: [MediaFileDraftModel]? + + + @Environment(\.appDatabase) private var appDatabase + @Environment(\.dismiss) private var dismiss + + var isPhotosPickerPresented: Binding { + .init( + get: { + importModel?.isPhotosPickerPresented ?? false + }, + set: { isPresented in + importModel?.isPhotosPickerPresented = isPresented + }) + } + + var photosPickerSelection: Binding<[PhotosPickerItem]> { + .init( + get: { + importModel?.photosPickerSelection ?? [] + }, + set: { newValue in + importModel?.photosPickerSelection = newValue + }) + } + + var isFileImporterPresented: Binding { + .init( + get: { + importModel?.isFileImporterPresented ?? false + }, + set: { newValue in + importModel?.isFileImporterPresented = newValue + }) + } + + var isCameraPresented: Binding { + .init( + get: { + importModel?.isCameraPresented ?? false + }, + set: { newValue in + importModel?.isCameraPresented = newValue + }) + } + + + 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, + matching: .any(of: [.images]), + preferredItemEncoding: .compatible, + photoLibrary: .shared() + ) + .fileImporter( + isPresented: isFileImporterPresented, + // https://commons.wikimedia.org/wiki/Commons:File_types + allowedContentTypes: [ + // .mp3, .wav, .midi, + .svg, .png, .webP, .gif, .jpeg, + // .mpeg, + // .pdf, + // .geoJSON, + ], + allowsMultipleSelection: false, + onCompletion: { result in + importModel?.handleFileImport(result: result) + } + ) + .fullScreenCover(isPresented: isCameraPresented) { + CameraImagePicker { image, metadata in + do { + try importModel?.handleCameraImage(image, metadata: metadata) + } catch { + logger.error("Failed to handle camera input \(error)") + } + } + .ignoresSafeArea(.container) + } + .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] + } else if fileCount > 1 { + draftedFileModels = Array(importModel.editedDrafts.values) + } + + } + } +} + +extension [MediaFileDraftModel]: @retroactive Identifiable { + public var id: String { + self.reduce("") { partialResult, next in + partialResult + next.id + } + } +} diff --git a/CommonsFinder/Views/FileCreateView/FileCreateView.swift b/CommonsFinder/Views/FileCreateView/FileCreateView.swift deleted file mode 100644 index 0ad1768..0000000 --- a/CommonsFinder/Views/FileCreateView/FileCreateView.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// FileCreateView.swift -// CommonsFinder -// -// Created by Tom Brewe on 08.10.24. -// - -import Nuke -import NukeUI -import OrderedCollections -import PhotosUI -import SwiftUI -import os.log - -//struct FileCreateView: View { -// @Environment(\.dismiss) private var dismiss -// @Environment(\.appDatabase) private var appDatabase -// @Environment(UploadManager.self) private var uploadManager -// @Environment(AccountModel.self) private var account -// -// @State private var model: FileCreateViewModel -// @State private var isInteractingWithScrollView = false -// -// @State private var biggerImage = false -// // @State private var isShowingDeleteDialog = false -// // @State private var isShowingUploadDialog = false -// // @State private var isShowingCloseConfirmationDialog = false -// // @State private var isShowingUploadDisabledAlert = false -// -// -// /// Initializes the FileEditView with a list of files. If files are empty, start with a blank view, where users add new files. -// /// - Parameter files: [MediaFile] -// init(appDatabase: AppDatabase, newDraftOptions: NewDraftOptions? = nil, files: [MediaFileDraft] = []) { -// // NOTE: It is ok to initialize the @State model in the init, as we don't expect -// // and are not interested in subsequent prop changes from the outside. -// model = FileCreateViewModel(appDatabase: appDatabase, existingDrafts: files, newDraftOptions: newDraftOptions) -// } -// -// /// Initializes the FileEditView with a file. -// /// - Parameter file: MediaFile -// init(appDatabase: AppDatabase, file: MediaFileDraft) { -// model = FileCreateViewModel(appDatabase: appDatabase, existingDrafts: [file]) -// } -// -// var body: some View { -// NavigationStack { -// VStack { -// if model.newDraftOptions?.source != nil { -// ProgressView().presentationDetents([.height(50)]) -// } -// else if model.fileCount == 0 { -// VStack { -// -// Button { -// model.isPhotosPickerPresented = true -// } label: { -// Label("Add from Photos", systemImage: "photo.badge.plus") -// .padding() -// } -// -// Button { -// model.isCameraPresented = true -// } label: { -// Label("Take new Photo", systemImage: "camera") -// .padding() -// } -// -// Button { -// model.isFileImporterPresented = true -// } label: { -// Label("Add from Files", systemImage: "folder") -// .padding() -// } -// -// } -// .glassButtonStyle() -// .padding() -// .presentationDetents([.fraction(0.4), .medium]) -// -// } else if model.editedDrafts.count == 1, let selectedID = model.selectedID, let singleSelectedModel = model.editedDrafts[selectedID] { -// SingleImageDraftView(model: singleSelectedModel) -// .interactiveDismissDisabled() -// .presentationDetents([.large]) -// } else if model.editedDrafts.count > 1 { -// -// // TODO: Design specialized batch edit where you edit the title etc. for all images -// // then copies with enumeration (1-99) to drafts. -// imageScrollView -// -// // TODO: debounce changes to selectedID to reduce redraws of the form when swiping really fast -// if !isInteractingWithScrollView, let selectedID = model.selectedID, let selectedModel = model.editedDrafts[selectedID] { -// SingleImageDraftView(model: selectedModel) -// .id(selectedID) // .id is explicitly set to allow animated transition. -// .interactiveDismissDisabled() -// } -// Spacer() -// } -// } -// .animation(.easeInOut, value: isInteractingWithScrollView) -// .animation(.default, value: biggerImage) -// .modifier(FilePickerModifer(options: <#T##NewDraftOptions?#>, appDatabase: appData, onFinished: <#T##(FileCreateViewModel) -> Void#>)) -// #if !os(macOS) -// .navigationBarTitleDisplayMode(.inline) -// #endif -// } -// .onChange(of: model.editedDrafts.isEmpty, initial: true) { -// // When initially adding a file, select it -// if model.selectedID == nil, !model.editedDrafts.isEmpty { -// withAnimation { -// model.selectedID = model.editedDrafts.values.first?.id -// } -// } -// } -// -// } -// -// -// @ViewBuilder -// private var imageScrollView: some View { -// let itemWidth: Double = 150 -// let itemHeight: Double = 150 -// -// ScrollView(.horizontal) { -// LazyHStack { -// ForEach(model.editedDrafts.values) { file in -// let isSelected = file.id == model.selectedID -// let imageURL: URL? = file.fileItem?.fileURL -// let imageRequest = ImageRequest( -// url: imageURL, processors: [.resize(height: itemHeight)] -// ) -// -// LazyImage(request: imageRequest) { phase in -// if let image = phase.image { -// image -// .resizable() -// .aspectRatio(contentMode: .fit) -// .transition(.blurReplace) -// } else { -// Color.clear.background(.regularMaterial) -// } -// } -// .clipShape(.rect(cornerRadius: 15)) -// .frame(width: itemWidth) -// .id(file.id) -// .scrollTransition(.interactive.threshold(.visible(0.7))) { content, phase in -// content -// .scaleEffect(phase.isIdentity ? 1 : 0.85) -// .blur(radius: phase.isIdentity ? 0 : 5) -// .opacity(phase.isIdentity ? 1 : 0.5) -// } -// .padding(.bottom, 10) -// .overlay( -// alignment: .bottom, -// content: { -// Color.primary -// .frame(height: 0.5) -// .animation(.spring.delay(0.35)) { -// $0.opacity(isSelected ? 1 : 0) -// } -// }) -// } -// } -// .scrollTargetLayout() -// .padding(.horizontal, itemWidth / 3) -// } -// .onScrollPhaseChange { oldPhase, newPhase in -// isInteractingWithScrollView = newPhase != .idle -// } -// .scrollIndicators(.hidden) -// .scrollTargetBehavior(.viewAligned) -// .scrollPosition(id: $model.selectedID, anchor: .center) -// .sensoryFeedback(.selection, trigger: model.selectedID) -// .safeAreaPadding(.horizontal, itemWidth / 2) -// .frame(height: itemHeight) -// } -//} - - -struct DraftSheetModifer: ViewModifier { - @Binding var model: FileCreateViewModel? - - @State private var draftedFileModel: MediaFileDraftModel? - - - @Environment(\.appDatabase) private var appDatabase - @Environment(\.dismiss) private var dismiss - - var isPhotosPickerPresented: Binding { - .init( - get: { - model?.isPhotosPickerPresented ?? false - }, - set: { isPresented in - model?.isPhotosPickerPresented = isPresented - }) - } - - var photosPickerSelection: Binding<[PhotosPickerItem]> { - .init( - get: { - model?.photosPickerSelection ?? [] - }, - set: { newValue in - model?.photosPickerSelection = newValue - }) - } - - var isFileImporterPresented: Binding { - .init( - get: { - model?.isFileImporterPresented ?? false - }, - set: { newValue in - model?.isFileImporterPresented = newValue - }) - } - - var isCameraPresented: Binding { - .init( - get: { - model?.isCameraPresented ?? false - }, - set: { newValue in - model?.isCameraPresented = newValue - }) - } - - - func body(content: Content) -> some View { - content - .sheet(item: $draftedFileModel, onDismiss: { model = nil }) { model in - NavigationStack { - SingleImageDraftView(model: model) - } - } - .photosPicker( - isPresented: isPhotosPickerPresented, - selection: photosPickerSelection, - // NOTE: For now only allow 1 image until - // multi-upload is refined. - maxSelectionCount: 1, - matching: .any(of: [.images]), - preferredItemEncoding: .compatible, - photoLibrary: .shared() - ) - .fileImporter( - isPresented: isFileImporterPresented, - // https://commons.wikimedia.org/wiki/Commons:File_types - allowedContentTypes: [ - // .mp3, .wav, .midi, - .svg, .png, .webP, .gif, .jpeg, - // .mpeg, - // .pdf, - // .geoJSON, - ], - allowsMultipleSelection: false, - onCompletion: { result in - model?.handleFileImport(result: result) - } - ) - .fullScreenCover(isPresented: isCameraPresented) { - CameraImagePicker { image, metadata in - do { - try model?.handleCameraImage(image, metadata: metadata) - } catch { - logger.error("Failed to handle camera input \(error)") - } - } - .ignoresSafeArea(.container) - } - .onChange(of: model?.importStatus) { - if let model, model.importStatus == .finished, model.editedDrafts.count == 1, let imported = model.editedDrafts.values.first { - draftedFileModel = imported - } - } - } -} diff --git a/CommonsFinder/Views/FileCreateView/Model/FileCreateViewModel.swift b/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift similarity index 98% rename from CommonsFinder/Views/FileCreateView/Model/FileCreateViewModel.swift rename to CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift index e97a47f..cbbbd39 100644 --- a/CommonsFinder/Views/FileCreateView/Model/FileCreateViewModel.swift +++ b/CommonsFinder/Views/FileCreateView/Model/FileImportModel.swift @@ -1,5 +1,5 @@ // -// DraftModel.swift +// FileImportModel.swift // CommonsFinder // // Created by Tom Brewe on 13.10.24. @@ -19,7 +19,7 @@ enum DraftError: Error { /// DraftModel models a drafting session where the user can add & remove files and also edit their metadata -@Observable class FileCreateViewModel: Identifiable { +@Observable class FileImportModel: Identifiable { private var photoImportTask: Task? let newDraftOptions: NewDraftOptions? From 1aef3ad91063b072f66f5509d05109d7bf0de186 Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Sun, 15 Mar 2026 16:53:32 +0100 Subject: [PATCH 12/13] update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f0affd1..02f6f2b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ The project is **currently work-in-progress**, but several core features already ## Demo videos and screenshots + +You can find more screenrecordings and screenshots [on the project page at Wikimedia Commons](https://commons.wikimedia.org/wiki/CommonsFinder_iOS_App). + ____________________________________ From 83d67849e6317672681bbcae3b41ea94aab989ca Mon Sep 17 00:00:00 2001 From: Tom Brewe Date: Tue, 17 Mar 2026 15:27:54 +0100 Subject: [PATCH 13/13] fix: use String(describing: error) instead of error.localizedDescription for potentially more useful, user-facing error messages to better debug upload issues --- .../Observable Models/UploadManager/UploadManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CommonsFinder/Observable Models/UploadManager/UploadManager.swift b/CommonsFinder/Observable Models/UploadManager/UploadManager.swift index 1053bfd..c09fc09 100644 --- a/CommonsFinder/Observable Models/UploadManager/UploadManager.swift +++ b/CommonsFinder/Observable Models/UploadManager/UploadManager.swift @@ -432,9 +432,9 @@ class UploadManager { if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) } - _ = try? setPublishingError(for: id, error: .urlError(urlErrorCode: urlError.errorCode, errorDescription: urlError.localizedDescription)) + _ = try? setPublishingError(for: id, error: .urlError(urlErrorCode: urlError.errorCode, errorDescription: String(describing: urlError))) case .unspecifiedError(let error): - _ = try? setPublishingError(for: id, error: .error(errorDescription: error.localizedDescription, recoverySuggestion: nil)) + _ = try? setPublishingError(for: id, error: .error(errorDescription: String(describing: error), recoverySuggestion: nil)) if #available(iOS 26.0, *) { bgTask?.setTaskCompleted(success: false) }