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 @@
#
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)
+
+[
](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)
}