From 587ddfdca619e889d46bd2129026d3ab0164f077 Mon Sep 17 00:00:00 2001 From: Kevin Ratzel Date: Fri, 23 Oct 2026 15:41:14 -0400 Subject: [PATCH] Add Wikidata search to wikidata value fields Typing in a wikidata or *:wikidata value field searches Wikidata (wbsearchentities) and offers matching items, showing each item's label, QID and description. Selecting one inserts the QID. Co-Authored-By: Claude Opus 5.5 --- src/GoMap.xcodeproj/project.pbxproj | 4 ++ src/POI/PresetValueTextField.swift | 45 ++++++++++++++ .../CustomViews/AutocompleteTextField.swift | 28 +++++++-- src/Shared/Utility/Wikidata.swift | 61 +++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/Shared/Utility/Wikidata.swift diff --git a/src/GoMap.xcodeproj/project.pbxproj b/src/GoMap.xcodeproj/project.pbxproj index ae5092831..748728f73 100644 --- a/src/GoMap.xcodeproj/project.pbxproj +++ b/src/GoMap.xcodeproj/project.pbxproj @@ -274,6 +274,7 @@ C365A563263FC53F007DEC94 /* TurnRestrictHwyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C365A562263FC53F007DEC94 /* TurnRestrictHwyView.swift */; }; C365A569263FCC9C007DEC94 /* TapAndDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C365A568263FCC9C007DEC94 /* TapAndDragGesture.swift */; }; C381C30B263FE518003142BA /* PushPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C381C30A263FE518003142BA /* PushPinView.swift */; }; + C3A1D0022F9A00010000A001 /* Wikidata.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A1D0012F9A00010000A001 /* Wikidata.swift */; }; C3D0969C263CBC1100E3B129 /* POIFeaturePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D0969B263CBC1100E3B129 /* POIFeaturePickerViewController.swift */; }; C3D096AC263D48F700E3B129 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D096AB263D48F700E3B129 /* AppDelegate.swift */; }; C3D096AE263D4BFB00E3B129 /* UploadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D096AD263D4BFB00E3B129 /* UploadViewController.swift */; }; @@ -661,6 +662,7 @@ C381C305263FD0CA003142BA /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = ""; }; C381C306263FD0E8003142BA /* UndoManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndoManager.swift; sourceTree = ""; }; C381C30A263FE518003142BA /* PushPinView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushPinView.swift; sourceTree = ""; }; + C3A1D0012F9A00010000A001 /* Wikidata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Wikidata.swift; sourceTree = ""; }; C3D0969B263CBC1100E3B129 /* POIFeaturePickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = POIFeaturePickerViewController.swift; sourceTree = ""; }; C3D0969F263CD4BD00E3B129 /* PersistentWebCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistentWebCache.swift; sourceTree = ""; }; C3D096A2263CEBBC00E3B129 /* EditorMapLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorMapLayer.swift; sourceTree = ""; }; @@ -1280,6 +1282,7 @@ 02771D702BFC15C300FE9B79 /* UserFileList.swift */, C381C305263FD0CA003142BA /* VectorMath.swift */, C3D096FA263ED2EC00E3B129 /* VoiceAnnouncement.swift */, + C3A1D0012F9A00010000A001 /* Wikidata.swift */, C341E1522639DECC0022E983 /* WikiPage.swift */, ); name = Utility; @@ -1761,6 +1764,7 @@ 023320A22BE07B2000E3311A /* DataOverlayLayer.swift in Sources */, 02F2449A264F635800C42E41 /* PathUtil.swift in Sources */, C341E1532639DECC0022E983 /* WikiPage.swift in Sources */, + C3A1D0022F9A00010000A001 /* Wikidata.swift in Sources */, 02B5669D294F9D7E00710632 /* MagnifyingGlass.swift in Sources */, 0286EAA62656FEAC007CFEE9 /* EditorMapLayer+Ocean.swift in Sources */, 023320A42BE1DB9900E3311A /* DPadView.swift in Sources */, diff --git a/src/POI/PresetValueTextField.swift b/src/POI/PresetValueTextField.swift index 6d38db449..a7f2f6ccd 100644 --- a/src/POI/PresetValueTextField.swift +++ b/src/POI/PresetValueTextField.swift @@ -121,6 +121,7 @@ class PresetValueTextField: AutocompleteTextField, PanoramaxDelegate { textColor = nil if key != "", + !isWikidataKey, PresetsDatabase.shared.eligibleForAutocomplete(key) { var set: Set = PresetsDatabase.shared.allTagValuesForKey(key) @@ -194,6 +195,50 @@ class PresetValueTextField: AutocompleteTextField, PanoramaxDelegate { text = value updateAssociatedContent() notifyValueChange(ended: true) + + wikidataSearchTask?.cancel() + wikidataSearchText = "" + completionLabels = [:] + } + + private var wikidataSearchTask: Task? + private var wikidataSearchText = "" + + private var isWikidataKey: Bool { + return OsmTags.isKey(key, variantOf: "wikidata") + } + + override func updateAutocomplete(for text: String) { + guard isWikidataKey else { + super.updateAutocomplete(for: text) + return + } + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + if text == wikidataSearchText { + showCompletions(filteredCompletions) + return + } + wikidataSearchText = text + wikidataSearchTask?.cancel() + guard text != "", !Wikidata.isQID(text) else { + showCompletions([]) + return + } + wikidataSearchTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 300_000000) + guard !Task.isCancelled, + let items = try? await Wikidata.search(text, + language: PresetLanguages.preferredPresetLanguageCode()), + !Task.isCancelled, + let self, + self.isEditing + else { return } + for item in items { + let subtitle = [item.id, item.description].compactMap { $0 }.joined(separator: " · ") + self.completionLabels[item.id] = (title: item.label, subtitle: subtitle) + } + self.showCompletions(items.map { $0.id }) + } } // MARK: Accessory buttons diff --git a/src/Shared/CustomViews/AutocompleteTextField.swift b/src/Shared/CustomViews/AutocompleteTextField.swift index 28f8e4976..f02063acb 100644 --- a/src/Shared/CustomViews/AutocompleteTextField.swift +++ b/src/Shared/CustomViews/AutocompleteTextField.swift @@ -64,6 +64,13 @@ class AutocompleteTextField: UITextField, UITableViewDataSource, UITableViewDele } } + var completionLabels: [String: (title: String, subtitle: String?)] = [:] + + func showCompletions(_ values: [String]) { + filteredCompletions = values + updateCompletionTableView() + } + func updateAutocomplete(for text: String) { let text = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) // filter completion list by current text @@ -241,15 +248,24 @@ class AutocompleteTextField: UITextField, UITableViewDataSource, UITableViewDele } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cellIdentifier = "Cell" + // shouldn't need this check but we got an out-of-range crash report + let value = indexPath.row < filteredCompletions.count ? filteredCompletions[indexPath.row] : "" + let label = completionLabels[value] + + let cellIdentifier = label != nil ? "SubtitleCell" : "Cell" let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) - ?? UITableViewCell(style: .default, reuseIdentifier: cellIdentifier) + ?? UITableViewCell(style: label != nil ? .subtitle : .default, reuseIdentifier: cellIdentifier) cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .body) - // shouldn't need this check but we got an out-of-range crash report - if indexPath.row < filteredCompletions.count { - cell.textLabel?.text = filteredCompletions[indexPath.row] + if let label { + cell.textLabel?.text = label.title + cell.detailTextLabel?.text = label.subtitle + if #available(iOS 13.0, *) { + cell.detailTextLabel?.textColor = .secondaryLabel + } else { + cell.detailTextLabel?.textColor = .gray + } } else { - cell.textLabel?.text = "" + cell.textLabel?.text = value } return cell } diff --git a/src/Shared/Utility/Wikidata.swift b/src/Shared/Utility/Wikidata.swift new file mode 100644 index 000000000..8a4a50c05 --- /dev/null +++ b/src/Shared/Utility/Wikidata.swift @@ -0,0 +1,61 @@ +// +// Wikidata.swift +// Go Map!! +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import Foundation + +struct WikidataItem { + let id: String + let label: String + let description: String? +} + +enum Wikidata { + private static let apiURL = URL(string: "https://www.wikidata.org/w/api.php")! + + static func isQID(_ value: String) -> Bool { + return value.range(of: "^Q[1-9][0-9]*$", options: .regularExpression) != nil + } + + static func search(_ text: String, language: String) async throws -> [WikidataItem] { + let language = language.lowercased() + let results = try await search(text, wikidataLanguage: language) + if results == nil, + let base = language.split(separator: "-").first, + base != language + { + return try await search(text, wikidataLanguage: String(base)) ?? [] + } + return results ?? [] + } + + private static func search(_ text: String, wikidataLanguage language: String) async throws -> [WikidataItem]? { + let url = apiURL.appendingQueryItems([ + "action": "wbsearchentities", + "format": "json", + "formatversion": "2", + "type": "item", + "limit": "10", + "search": text, + "language": language, + "uselang": language + ]) + let data = try await URLSession.shared.data(with: url) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] } + if let error = json["error"] as? [String: Any], + error["code"] as? String == "badvalue" + { + return nil + } + guard let results = json["search"] as? [[String: Any]] else { return [] } + return results.compactMap { result in + guard let id = result["id"] as? String else { return nil } + return WikidataItem(id: id, + label: result["label"] as? String ?? id, + description: result["description"] as? String) + } + } +}