From 5de98ea762221b9b61cdb452cc9a31113e66057d Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 00:20:10 +0530 Subject: [PATCH 1/8] feat: Add app pricing comparison, schema V2, and expanded MCP tools - New pricing comparison feature: AppPricingSnapshot model, AppPricingPersistence, AppPricingView, and per-storefront price fetching with USD/tax-adjusted comparisons - Migrate SwiftData schema to OpenASOSchemaV2 - Expand MCP server with workspace/app resources, global keyword CRUD, keyword track mutations, pricing comparison, ranking and rating history tools - Add rankingDifficulty metrics source, batched storefront popularity fetching, and reworked ranking refresh coordination Co-Authored-By: Claude Fable 5 --- OpenASO.xcodeproj/project.pbxproj | 43 +- OpenASO/App/AppServices.swift | 23 +- OpenASO/App/RootSidebarView.swift | 7 +- .../AppDetail/AppDetailSupportTypes.swift | 10 + .../AppDetail/AppDetailToolbarViews.swift | 147 ++ .../Features/AppDetail/AppDetailView.swift | 877 +++++++-- .../AppDetail/Keywords/AddKeywordsSheet.swift | 1044 +++++++++- .../Table/KeywordRankingListSheet.swift | 14 +- .../AppDetail/Pricing/AppPricingView.swift | 446 +++++ OpenASO/Models/AppPricingSnapshot.swift | 34 + OpenASO/Models/KeywordMetricsSource.swift | 3 + OpenASO/Models/OpenASOSchemaV1.swift | 39 +- .../AppDetail/AppDetailRefreshService.swift | 230 ++- .../AppDetail/AppPricingPersistence.swift | 82 + .../AppDetail/AppRefreshProgressStore.swift | 12 + .../AppleAds/AppleAdsWebSession.swift | 2 +- .../AppleAds/KeywordMetricsService.swift | 144 +- OpenASO/Services/MCP/OpenASOMCPDTOs.swift | 137 ++ OpenASO/Services/MCP/OpenASOMCPServer.swift | 307 ++- OpenASO/Services/MCP/OpenASOMCPService.swift | 1743 +++++++++++++++-- .../Persistence/ModelContainerFactory.swift | 2 +- .../AppStorefrontRatingService.swift | 142 +- .../RankingRefreshCoordinator.swift | 314 ++- .../AppStoreWebMetadataProvider.swift | 4 +- .../Storefront/StorefrontCatalog.swift | 7 + 25 files changed, 5264 insertions(+), 549 deletions(-) create mode 100644 OpenASO/Features/AppDetail/Pricing/AppPricingView.swift create mode 100644 OpenASO/Models/AppPricingSnapshot.swift create mode 100644 OpenASO/Services/AppDetail/AppPricingPersistence.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index 1922706..e75798f 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -104,6 +104,10 @@ A40000000000000000000007 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000017 /* ReviewCard.swift */; }; A40000000000000000000008 /* ReviewReplySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000018 /* ReviewReplySheet.swift */; }; A40000000000000000000009 /* ReviewFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000019 /* ReviewFilters.swift */; }; + A4000000000000000000000A /* AppPricingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001A /* AppPricingView.swift */; }; + A4000000000000000000000B /* AppPricingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001B /* AppPricingSnapshot.swift */; }; + A4000000000000000000000C /* AppPricingPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */; }; + A4000000000000000000000D /* AppPricingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001B /* AppPricingSnapshot.swift */; }; A50000000000000000000001 /* PaginatedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50000000000000000000002 /* PaginatedList.swift */; }; A60000000000000000000001 /* AIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60000000000000000000002 /* AIServiceTests.swift */; }; A60000000000000000000003 /* ReviewTranslationIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */; }; @@ -297,6 +301,9 @@ A40000000000000000000017 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = ""; }; A40000000000000000000018 /* ReviewReplySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewReplySheet.swift; sourceTree = ""; }; A40000000000000000000019 /* ReviewFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewFilters.swift; sourceTree = ""; }; + A4000000000000000000001A /* AppPricingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPricingView.swift; sourceTree = ""; }; + A4000000000000000000001B /* AppPricingSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPricingSnapshot.swift; sourceTree = ""; }; + A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppPricingPersistence.swift; sourceTree = ""; }; A50000000000000000000002 /* PaginatedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedList.swift; sourceTree = ""; }; A60000000000000000000002 /* AIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewTranslationIntegrationTests.swift; sourceTree = ""; }; @@ -442,6 +449,7 @@ 04E8C4E18F1A4D519C0A1131 /* AI/AITranslationService.swift */, A80000000000000000000002 /* AnalyticsService.swift */, 04E8C4E18F1A4D519C0A1141 /* AppDetail/AppDetailRefreshService.swift */, + A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */, 04E8C4E18F1A4D519C0A1143 /* AppDetail/AppRefreshProgressStore.swift */, 04E8C4E18F1A4D519C0A1105 /* Storefront/AppCatalogService.swift */, 04E8C4E18F1A4D519C0A1107 /* Storefront/AppStoreWebMetadataProvider.swift */, @@ -539,6 +547,14 @@ path = Ratings; sourceTree = ""; }; + A40000000000000000000021 /* Pricing */ = { + isa = PBXGroup; + children = ( + A4000000000000000000001A /* AppPricingView.swift */, + ); + path = Pricing; + sourceTree = ""; + }; C0DEC0DE0000000000000000 /* Views */ = { isa = PBXGroup; children = ( @@ -554,6 +570,14 @@ path = Views; sourceTree = ""; }; + C0DEC0DE0000000000000010 /* MCP */ = { + isa = PBXGroup; + children = ( + C0DEC0DE0000000000000011 /* MCPServerSheet.swift */, + ); + path = MCP; + sourceTree = ""; + }; C10000000000000000000001 /* AppDetail */ = { isa = PBXGroup; children = ( @@ -564,6 +588,7 @@ A0D71B4C0000000000000001 /* AppDetailToolbarViews.swift */, C10000000000000000000002 /* Keywords */, A40000000000000000000020 /* Ratings */, + A40000000000000000000021 /* Pricing */, ); path = AppDetail; sourceTree = ""; @@ -610,14 +635,6 @@ path = Settings; sourceTree = ""; }; - C0DEC0DE0000000000000010 /* MCP */ = { - isa = PBXGroup; - children = ( - C0DEC0DE0000000000000011 /* MCPServerSheet.swift */, - ); - path = MCP; - sourceTree = ""; - }; C5A400000000000000000005 /* Utilities */ = { isa = PBXGroup; children = ( @@ -635,6 +652,7 @@ BEEF0000000000000000000D /* AppDailyRating.swift */, 0A1B2C3D4E5F678901234510 /* AppFolder.swift */, 0A1B2C3D4E5F678901234506 /* AppKeywordStats.swift */, + A4000000000000000000001B /* AppPricingSnapshot.swift */, BEEF00000000000000000019 /* AppStoreScreenshot.swift */, BEEF00000000000000000017 /* AppStorefrontMetadata.swift */, 0A1B2C3D4E5F678901234512 /* AppStorefrontReview.swift */, @@ -919,6 +937,9 @@ A40000000000000000000006 /* ReviewsPageLoader.swift in Sources */, A40000000000000000000004 /* RatingsReviewsView.swift in Sources */, A40000000000000000000002 /* RatingsSidebar.swift in Sources */, + A4000000000000000000000A /* AppPricingView.swift in Sources */, + A4000000000000000000000B /* AppPricingSnapshot.swift in Sources */, + A4000000000000000000000C /* AppPricingPersistence.swift in Sources */, A70000000000000000000001 /* RatingsReviews/ReviewLanguageDetectionService.swift in Sources */, A70100000000000000000003 /* RatingsReviews/AppStorefrontRatingParser.swift in Sources */, A70100000000000000000001 /* RatingsReviews/AppStorefrontRatingTypes.swift in Sources */, @@ -985,6 +1006,7 @@ C15A0000000000000000010D /* Storefront.swift in Sources */, C15A0000000000000000010E /* AppFolder.swift in Sources */, C15A0000000000000000010F /* AppKeywordStats.swift in Sources */, + A4000000000000000000000D /* AppPricingSnapshot.swift in Sources */, C15A00000000000000000110 /* AppStorefrontReview.swift in Sources */, C15A00000000000000000111 /* StoreApp.swift in Sources */, C15A00000000000000000112 /* AppStorefrontMetadata.swift in Sources */, @@ -1049,11 +1071,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 85BF4D5D6B; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OpenASO/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "OpenASO Dev"; @@ -1282,7 +1305,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 85BF4D5D6B; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OpenASO/Info.plist; diff --git a/OpenASO/App/AppServices.swift b/OpenASO/App/AppServices.swift index 749c976..6a8d9dd 100644 --- a/OpenASO/App/AppServices.swift +++ b/OpenASO/App/AppServices.swift @@ -32,6 +32,7 @@ final class AppServices { let refreshCoordinator: RankingRefreshCoordinator let appDetailRefreshService: AppDetailRefreshService? let refreshProgressStore: AppRefreshProgressStore + let appPricingCache: AppPricingCache let mcpServerController: OpenASOMCPServerController private(set) var backgroundModelStore: BackgroundModelStore? private(set) var backgroundModelStoreRevision = 0 @@ -100,6 +101,7 @@ final class AppServices { let keywordInsightsService = KeywordInsightsService() let rankingProvider = ITunesSearchFallbackProvider(httpClient: httpClient) let refreshProgressStore = AppRefreshProgressStore() + let appPricingCache = AppPricingCache() let storefrontCatalog = StorefrontCatalog() let metadataEnrichmentHandler: (@Sendable ([RankingMetadataEnrichmentRequest]) async -> Void)? if let backgroundModelStore { @@ -179,9 +181,9 @@ final class AppServices { }, metadataEnrichmentHandler: metadataEnrichmentHandler ) - let appDetailRefreshService = backgroundModelStore.map { + let appDetailRefreshService = backgroundModelStore.map { backgroundModelStore in AppDetailRefreshService( - backgroundModelStore: $0, + backgroundModelStore: backgroundModelStore, refreshCoordinator: refreshCoordinator, keywordMetricsService: keywordMetricsService, appStorefrontRatingService: appStorefrontRatingService, @@ -190,7 +192,21 @@ final class AppServices { progressStore: refreshProgressStore, ratingsReviewsRefreshRecorder: { date in await settingsStore.markRatingsReviewsRefreshed(on: date) - } + }, + pricingRefresh: { appStoreID, storefronts, progress in + let service = OpenASOMCPService( + backgroundModelStore: backgroundModelStore, + appResolver: resolver, + appCatalogService: catalogService, + httpClient: httpClient + ) + return try await service.compareAppPricing( + appStoreIDs: [appStoreID], + storefronts: storefronts, + progress: progress + ) + }, + pricingCache: appPricingCache ) } @@ -246,6 +262,7 @@ final class AppServices { self.refreshCoordinator = refreshCoordinator self.appDetailRefreshService = appDetailRefreshService self.refreshProgressStore = refreshProgressStore + self.appPricingCache = appPricingCache self.mcpServerController = OpenASOMCPServerController(portProvider: { settingsStore.mcpServerPort }) { diff --git a/OpenASO/App/RootSidebarView.swift b/OpenASO/App/RootSidebarView.swift index 86138cf..c5017d5 100644 --- a/OpenASO/App/RootSidebarView.swift +++ b/OpenASO/App/RootSidebarView.swift @@ -792,6 +792,7 @@ private struct SidebarRefreshProgressView: View { SidebarRefreshProgressRow(title: "Keywords & Metrics", progress: refresh.keywordAndMetricsProgress) SidebarRefreshProgressRow(title: "Ratings", progress: refresh.ratingsProgress) SidebarRefreshProgressRow(title: "Reviews", progress: refresh.reviewsProgress) + SidebarRefreshProgressRow(title: "Pricing", progress: refresh.pricingProgress) SidebarPendingAppRefreshesRow(appCount: pendingAppRefreshCount) SidebarPendingKeywordAdditionsRow(trackCount: pendingKeywordTrackCount) } @@ -854,7 +855,7 @@ private struct SidebarRefreshProgressView: View { return "App data ready" case .failed: return "App data update failed" - case .refreshingKeywords, .refreshingMetrics: + case .refreshingKeywords, .refreshingMetrics, .refreshingPricing: return refresh.phase.title } } @@ -873,7 +874,7 @@ private struct SidebarRefreshProgressView: View { return "Keyword data ready" case .failed: return "Keyword data update failed" - case .refreshingRatings, .refreshingReviews: + case .refreshingRatings, .refreshingReviews, .refreshingPricing: return refresh.phase.title } } @@ -892,7 +893,7 @@ private struct SidebarRefreshProgressView: View { return "Imported keywords ready" case .failed: return "Imported keyword update failed" - case .refreshingRatings, .refreshingReviews: + case .refreshingRatings, .refreshingReviews, .refreshingPricing: return refresh.phase.title } } diff --git a/OpenASO/Features/AppDetail/AppDetailSupportTypes.swift b/OpenASO/Features/AppDetail/AppDetailSupportTypes.swift index d92019f..c737727 100644 --- a/OpenASO/Features/AppDetail/AppDetailSupportTypes.swift +++ b/OpenASO/Features/AppDetail/AppDetailSupportTypes.swift @@ -38,7 +38,9 @@ enum TrendDateRange: CaseIterable, Identifiable { enum AppDetailWorkspaceView: String, CaseIterable, Identifiable { case keywords + case competitors case ratings + case pricing var id: String { rawValue } @@ -46,8 +48,12 @@ enum AppDetailWorkspaceView: String, CaseIterable, Identifiable { switch self { case .keywords: return "Keywords" + case .competitors: + return "Competitors" case .ratings: return "Ratings" + case .pricing: + return "Pricing" } } @@ -55,8 +61,12 @@ enum AppDetailWorkspaceView: String, CaseIterable, Identifiable { switch self { case .keywords: return "Search keywords" + case .competitors: + return "Search opportunities" case .ratings: return "Search" + case .pricing: + return "Search plans" } } } diff --git a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift index 2656874..6378868 100644 --- a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift +++ b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift @@ -1,11 +1,87 @@ import SwiftData import SwiftUI +struct AppDetailRefreshDataSelection: Equatable, Sendable { + var refreshRankings: Bool + var refreshMetrics: Bool + var refreshRatings: Bool + var refreshReviews: Bool + var refreshPricing: Bool + + static let pricing = AppDetailRefreshDataSelection( + refreshRankings: false, + refreshMetrics: false, + refreshRatings: false, + refreshReviews: false, + refreshPricing: true + ) + static let rankings = AppDetailRefreshDataSelection( + refreshRankings: true, + refreshMetrics: false, + refreshRatings: false, + refreshReviews: false, + refreshPricing: false + ) + static let metrics = AppDetailRefreshDataSelection( + refreshRankings: false, + refreshMetrics: true, + refreshRatings: false, + refreshReviews: false, + refreshPricing: false + ) + static let rankingsAndMetrics = AppDetailRefreshDataSelection( + refreshRankings: true, + refreshMetrics: true, + refreshRatings: false, + refreshReviews: false, + refreshPricing: false + ) + static let ratings = AppDetailRefreshDataSelection( + refreshRankings: false, + refreshMetrics: false, + refreshRatings: true, + refreshReviews: false, + refreshPricing: false + ) + static let reviews = AppDetailRefreshDataSelection( + refreshRankings: false, + refreshMetrics: false, + refreshRatings: false, + refreshReviews: true, + refreshPricing: false + ) + static let ratingsAndReviews = AppDetailRefreshDataSelection( + refreshRankings: false, + refreshMetrics: false, + refreshRatings: true, + refreshReviews: true, + refreshPricing: false + ) + static let allData = AppDetailRefreshDataSelection( + refreshRankings: true, + refreshMetrics: true, + refreshRatings: true, + refreshReviews: true, + refreshPricing: true + ) + + var title: String { + var parts: [String] = [] + if refreshRankings { parts.append("rankings") } + if refreshMetrics { parts.append("metrics") } + if refreshRatings { parts.append("ratings") } + if refreshReviews { parts.append("reviews") } + if refreshPricing { parts.append("pricing") } + return parts.isEmpty ? "nothing" : parts.joined(separator: ", ") + } +} + struct AppDetailRefreshToolbarButton: View { let isRefreshing: Bool let isDisabled: Bool let action: () -> Void let refreshAllAction: () -> Void + let refreshAllSelectedDataAction: (AppDetailRefreshDataSelection) -> Void var body: some View { Menu { @@ -15,6 +91,65 @@ struct AppDetailRefreshToolbarButton: View { Label("Refresh All Apps", systemImage: "arrow.triangle.2.circlepath") } .disabled(isDisabled) + + Divider() + + Menu { + Button { + refreshAllSelectedDataAction(.pricing) + } label: { + Label("Pricing", systemImage: "tag") + } + + Button { + refreshAllSelectedDataAction(.rankings) + } label: { + Label("Keyword Rankings", systemImage: "chart.line.uptrend.xyaxis") + } + + Button { + refreshAllSelectedDataAction(.metrics) + } label: { + Label("Keyword Metrics", systemImage: "gauge.with.dots.needle.67percent") + } + + Button { + refreshAllSelectedDataAction(.rankingsAndMetrics) + } label: { + Label("Rankings + Metrics", systemImage: "chart.xyaxis.line") + } + + Divider() + + Button { + refreshAllSelectedDataAction(.ratings) + } label: { + Label("Ratings", systemImage: "star") + } + + Button { + refreshAllSelectedDataAction(.reviews) + } label: { + Label("Reviews", systemImage: "text.bubble") + } + + Button { + refreshAllSelectedDataAction(.ratingsAndReviews) + } label: { + Label("Ratings + Reviews", systemImage: "star.bubble") + } + + Divider() + + Button { + refreshAllSelectedDataAction(.allData) + } label: { + Label("All Data", systemImage: "square.stack.3d.up") + } + } label: { + Label("Refresh All Apps With Selected Data", systemImage: "checklist") + } + .disabled(isDisabled) } label: { Label("Refresh", systemImage: "arrow.clockwise") } primaryAction: { @@ -205,6 +340,18 @@ struct AppDetailAddKeywordsToolbarButton: View { } } +struct AppDetailKeywordListsToolbarButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Keyword Lists", systemImage: "list.bullet.rectangle") + .labelStyle(.titleAndIcon) + } + .help("View and edit keyword lists") + } +} + private struct AppDetailFilterButton: View { @Binding var selectedPlatformFilter: PlatformFilter @Binding var popularityFilterRange: ClosedRange diff --git a/OpenASO/Features/AppDetail/AppDetailView.swift b/OpenASO/Features/AppDetail/AppDetailView.swift index d23e9b0..99d2593 100644 --- a/OpenASO/Features/AppDetail/AppDetailView.swift +++ b/OpenASO/Features/AppDetail/AppDetailView.swift @@ -7,7 +7,7 @@ struct AppDetailView: View { @Query(sort: [ SortDescriptor(\TrackedApp.sidebarSortOrder, order: .forward), - SortDescriptor(\TrackedApp.appStoreID, order: .forward) + SortDescriptor(\TrackedApp.appStoreID, order: .forward), ]) private var trackedApps: [TrackedApp] @@ -20,6 +20,7 @@ struct AppDetailView: View { private let defaultPlatform: AppPlatform @State private var isPresentingAddKeywords = false + @State private var isPresentingKeywordLists = false @State private var isRefreshingApp = false @State private var errorMessage: String? @State private var searchText = "" @@ -27,6 +28,7 @@ struct AppDetailView: View { @State private var selectedStorefrontFilter = StorefrontFilter.all @State private var keywordWorkspaceState = KeywordWorkspaceState() @State private var ratingsRefreshToken = 0 + @State private var pricingRefreshToken = 0 @State private var isImportingCSV = false @State private var isProcessingCSVImport = false @State private var isExportingCSV = false @@ -37,6 +39,7 @@ struct AppDetailView: View { @State private var queuedKeywordAdds: [KeywordAddRequest] = [] @State private var isFlushingQueuedKeywordAdds = false @State private var isRefreshingQueuedKeywordAdds = false + @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" init(trackedApp: TrackedApp) { self.trackedApp = trackedApp @@ -65,6 +68,24 @@ struct AppDetailView: View { searchText: searchText, refreshToken: ratingsRefreshToken ) + } else if selectedWorkspaceView == .pricing { + AppPricingView( + appStoreID: appStoreID, + selectedStorefrontFilter: selectedStorefrontFilter, + searchText: searchText, + refreshToken: pricingRefreshToken + ) + } else if selectedWorkspaceView == .competitors { + CompetitorComparisonView( + trackedApp: trackedApp, + trackedApps: trackedApps, + selectedStorefrontFilter: selectedStorefrontFilter, + searchText: searchText, + reportError: setErrorMessage, + didAddKeyword: { + keywordRefreshToken += 1 + } + ) } else { AppKeywordsView( trackedApp: trackedApp, @@ -90,7 +111,8 @@ struct AppDetailView: View { isRefreshing: isRefreshingApp, isDisabled: isRefreshDisabled, action: refreshApp, - refreshAllAction: refreshAllApps + refreshAllAction: refreshAllApps, + refreshAllSelectedDataAction: refreshAllApps ) AppDetailWorkspaceViewPicker(selectedWorkspaceView: $selectedWorkspaceView) AppDetailStorefrontPickerButton( @@ -109,8 +131,13 @@ struct AppDetailView: View { if selectedWorkspaceView == .keywords { ToolbarItem(placement: .principal) { - AppDetailAddKeywordsToolbarButton { - isPresentingAddKeywords = true + HStack(spacing: 10) { + AppDetailKeywordListsToolbarButton { + isPresentingKeywordLists = true + } + AppDetailAddKeywordsToolbarButton { + isPresentingAddKeywords = true + } } } } @@ -137,9 +164,12 @@ struct AppDetailView: View { ) } } - .sheet(isPresented: $isPresentingAddKeywords, onDismiss: { - keywordRefreshToken += 1 - }) { + .sheet( + isPresented: $isPresentingAddKeywords, + onDismiss: { + keywordRefreshToken += 1 + } + ) { AddKeywordsSheet( trackedApp: trackedApp, initialStorefrontCode: addKeywordsInitialStorefrontCode, @@ -147,6 +177,14 @@ struct AppDetailView: View { queueKeywordAdd: queueKeywordAdd ) } + .sheet( + isPresented: $isPresentingKeywordLists, + onDismiss: { + keywordRefreshToken += 1 + } + ) { + ManageKeywordListsSheet(trackedApp: trackedApp) + } .onAppear { services.analyticsService.capture(.workspaceViewed(selectedWorkspaceView)) flushQueuedKeywordAdds() @@ -168,8 +206,9 @@ struct AppDetailView: View { contentType: .commaSeparatedText, defaultFilename: exportDefaultFilename ) { result in - if case let .failure(error) = result { - transferAlert = TrackedKeywordTransferAlert(title: "Export Failed", message: error.localizedDescription) + if case .failure(let error) = result { + transferAlert = TrackedKeywordTransferAlert( + title: "Export Failed", message: error.localizedDescription) } } .fileImporter( @@ -213,16 +252,17 @@ struct AppDetailView: View { switch refresh.phase { case .completed, .failed: return false - case .preparing, .refreshingKeywords, .refreshingMetrics, .refreshingRatings, .refreshingReviews, .finishing: + case .preparing, .refreshingKeywords, .refreshingMetrics, .refreshingRatings, + .refreshingReviews, .refreshingPricing, .finishing: return true } } private var activeKeywordMetricsRefreshSignature: String? { guard let refresh = services.refreshProgressStore.activeRefresh, - refresh.appStoreID == appStoreID, - refresh.metricsProgress.total > 0, - refresh.metricsProgress.completed > 0 + refresh.appStoreID == appStoreID, + refresh.metricsProgress.total > 0, + refresh.metricsProgress.completed > 0 else { return nil } @@ -231,7 +271,7 @@ struct AppDetailView: View { refresh.id.uuidString, String(refresh.metricsProgress.completed), String(refresh.metricsProgress.failureCount), - String(describing: refresh.metricsProgress.status) + String(describing: refresh.metricsProgress.status), ].joined(separator: "::") } @@ -267,6 +307,7 @@ struct AppDetailView: View { } keywordRefreshToken += 1 ratingsRefreshToken += 1 + pricingRefreshToken += 1 OpenASOLog.appDetail.info( "Refresh finished appStoreID=\(appStoreID, privacy: .public) ratingSuccesses=\(result.ratingOutcomes.filter { $0.error == nil }.count, privacy: .public) ratingFailures=\(result.ratingOutcomes.filter { $0.error != nil }.count, privacy: .public) reviewSuccesses=\(result.reviewOutcomes.filter { $0.error == nil }.count, privacy: .public) reviewFailures=\(result.reviewOutcomes.filter { $0.error != nil }.count, privacy: .public) keywordFailures=\(result.keywordOutcomes.filter { $0.error != nil }.count, privacy: .public)" @@ -278,6 +319,14 @@ struct AppDetailView: View { } private func refreshAllApps() { + refreshAllApps(selection: nil) + } + + private func refreshAllApps(_ selection: AppDetailRefreshDataSelection) { + refreshAllApps(selection: selection) + } + + private func refreshAllApps(selection: AppDetailRefreshDataSelection?) { isRefreshingApp = true errorMessage = nil let activeWorkspaceView = selectedWorkspaceView @@ -288,7 +337,11 @@ struct AppDetailView: View { guard let service = services.appDetailRefreshService else { throw OpenASOError.providerUnavailable("The background model store is unavailable.") } - requests = try makeRefreshAllRequests(activeWorkspaceView: activeWorkspaceView) + if let selection { + requests = try makeRefreshAllRequests(selection: selection) + } else { + requests = try makeRefreshAllRequests(activeWorkspaceView: activeWorkspaceView) + } refreshService = service } catch { errorMessage = OpenASOError.map(error).localizedDescription @@ -297,7 +350,7 @@ struct AppDetailView: View { } OpenASOLog.appDetail.info( - "Refresh all tapped startingAppStoreID=\(appStoreID, privacy: .public) view=\(activeWorkspaceView.title, privacy: .public) selectedStorefront=\(selectedStorefrontFilter.title, privacy: .public) appCount=\(requests.count, privacy: .public)" + "Refresh all tapped startingAppStoreID=\(appStoreID, privacy: .public) view=\(activeWorkspaceView.title, privacy: .public) selectedData=\(selection?.title ?? "current view", privacy: .public) selectedStorefront=\(selectedStorefrontFilter.title, privacy: .public) requestCount=\(requests.count, privacy: .public)" ) Task(priority: .userInitiated) { @@ -323,6 +376,7 @@ struct AppDetailView: View { await MainActor.run { keywordRefreshToken += 1 ratingsRefreshToken += 1 + pricingRefreshToken += 1 } } } @@ -333,6 +387,7 @@ struct AppDetailView: View { } keywordRefreshToken += 1 ratingsRefreshToken += 1 + pricingRefreshToken += 1 OpenASOLog.appDetail.info( "Refresh all finished startingAppStoreID=\(appStoreID, privacy: .public) appCount=\(requests.count, privacy: .public) ratingSuccesses=\(ratingSuccesses, privacy: .public) ratingFailures=\(ratingFailures, privacy: .public) reviewSuccesses=\(reviewSuccesses, privacy: .public) reviewFailures=\(reviewFailures, privacy: .public) keywordFailures=\(keywordFailures, privacy: .public)" @@ -347,7 +402,7 @@ struct AppDetailView: View { queuedKeywordAdds.append(request) services.refreshProgressStore.queuePendingKeywordAddition( appStoreID: appStoreID, - trackCount: request.keywords.count * request.storefrontCodes.count + trackCount: request.candidates.count ) flushQueuedKeywordAdds() } @@ -382,40 +437,39 @@ struct AppDetailView: View { for request in requests { requestedKeywordCount += request.keywords.count requestedStorefrontCodes.formUnion(request.storefrontCodes) - let platform = request.platform - - for storefrontCode in request.storefrontCodes.sorted() { - for keyword in request.keywords { - let identityKey = keywordDuplicateKey(term: keyword, storefront: storefrontCode, platform: platform) - guard !mutableExistingKeys.contains(identityKey) else { continue } - - let query: KeywordQuery - do { - query = try KeywordQuery.fetchOrInsert( - term: keyword, - storefront: storefrontCode, - platform: platform, - in: modelContext - ) - } catch { - errorMessage = OpenASOError.map(error).localizedDescription - return - } - let track = TrackedAppKeyword( - term: keyword, - storefront: storefrontCode, - platform: platform, - trackedApp: trackedApp, - query: query - ) + for candidate in request.candidates { + let identityKey = keywordDuplicateKey( + term: candidate.keyword, storefront: candidate.storefrontCode, + platform: candidate.platform) + guard !mutableExistingKeys.contains(identityKey) else { continue } - trackedApp.keywordTracks.append(track) - modelContext.insert(track) - mutableExistingKeys.insert(identityKey) - insertedStorefrontCodes.insert(storefrontCode) - insertedTracks.append(track) + let query: KeywordQuery + do { + query = try KeywordQuery.fetchOrInsert( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform, + in: modelContext + ) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + return } + + let track = TrackedAppKeyword( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform, + trackedApp: trackedApp, + query: query + ) + + trackedApp.keywordTracks.append(track) + modelContext.insert(track) + mutableExistingKeys.insert(identityKey) + insertedStorefrontCodes.insert(candidate.storefrontCode) + insertedTracks.append(track) } } @@ -431,10 +485,11 @@ struct AppDetailView: View { } keywordRefreshToken += 1 - services.analyticsService.capture(.keywordAdded( - keywordCount: requestedKeywordCount, - storefrontCount: requestedStorefrontCodes.count - )) + services.analyticsService.capture( + .keywordAdded( + keywordCount: requestedKeywordCount, + storefrontCount: requestedStorefrontCodes.count + )) guard let refreshService = services.appDetailRefreshService else { return @@ -468,24 +523,37 @@ struct AppDetailView: View { } } - private func makeRefreshRequest(activeWorkspaceView: AppDetailWorkspaceView) throws -> AppDetailRefreshRequest { - try makeRefreshRequest( + private func makeRefreshRequest(activeWorkspaceView: AppDetailWorkspaceView) throws + -> AppDetailRefreshRequest + { + let storefrontSelection = try makeRefreshStorefrontSelection() + let tracks = try trackedKeywordsForRefresh( + app: trackedApp, + storefrontSelection: storefrontSelection, + platformFilter: keywordWorkspaceState.selectedPlatformFilter + ) + return try makeRefreshRequest( for: appSnapshot, - trackIdentityKeys: fetchTrackedKeywords(for: appStoreID, platformFilter: keywordWorkspaceState.selectedPlatformFilter).map(\.identityKey), + storefrontSelection: storefrontSelection, + trackIdentityKeys: tracks.map(\.identityKey), activeWorkspaceView: activeWorkspaceView, trigger: "manual" ) } - private func makeRefreshAllRequests(activeWorkspaceView: AppDetailWorkspaceView) throws -> [AppDetailRefreshRequest] { + private func makeRefreshAllRequests(activeWorkspaceView: AppDetailWorkspaceView) throws + -> [AppDetailRefreshRequest] + { let orderedApps = trackedAppsForRefreshAll() let platformFilter = keywordWorkspaceState.selectedPlatformFilter - let tracksByAppStoreID = Dictionary( - grouping: try fetchAllTrackedKeywords().filter { platformFilter.matches($0.platform) }, - by: \.appStoreID - ) + let storefrontSelection = try makeRefreshStorefrontSelection() return try orderedApps.map { app in - try makeRefreshRequest( + let tracks = try trackedKeywordsForRefresh( + app: app, + storefrontSelection: storefrontSelection, + platformFilter: platformFilter + ) + return try makeRefreshRequest( for: AppDetailRefreshAppSnapshot( appStoreID: app.appStoreID, bundleID: app.bundleID, @@ -494,13 +562,73 @@ struct AppDetailView: View { sellerName: app.sellerName, defaultPlatform: app.defaultPlatform ), - trackIdentityKeys: (tracksByAppStoreID[app.appStoreID] ?? []).map(\.identityKey), + storefrontSelection: storefrontSelection, + trackIdentityKeys: tracks.map(\.identityKey), activeWorkspaceView: activeWorkspaceView, trigger: "manual_all" ) } } + private func makeRefreshAllRequests(selection: AppDetailRefreshDataSelection) throws + -> [AppDetailRefreshRequest] + { + let orderedApps = trackedAppsForRefreshAll() + let platformFilter = keywordWorkspaceState.selectedPlatformFilter + let storefrontSelection = try makeRefreshStorefrontSelection() + return try orderedApps.flatMap { trackedApp -> [AppDetailRefreshRequest] in + let app = AppDetailRefreshAppSnapshot( + appStoreID: trackedApp.appStoreID, + bundleID: trackedApp.bundleID, + name: trackedApp.name, + subtitle: trackedApp.subtitle, + sellerName: trackedApp.sellerName, + defaultPlatform: trackedApp.defaultPlatform + ) + var requests: [AppDetailRefreshRequest] = [] + + if selection.refreshRankings || selection.refreshMetrics || selection.refreshRatings || selection.refreshReviews { + let tracks: [TrackedAppKeyword] + if selection.refreshRankings || selection.refreshMetrics { + tracks = try trackedKeywordsForRefresh( + app: trackedApp, + storefrontSelection: storefrontSelection, + platformFilter: platformFilter + ) + } else { + tracks = [] + } + requests.append(try makeRefreshRequest( + for: app, + storefrontSelection: storefrontSelection, + trackIdentityKeys: tracks.map(\.identityKey), + workspace: selection.refreshRankings || selection.refreshMetrics ? .keywords : .ratings, + trigger: "manual_all_selected", + refreshKeywords: selection.refreshRankings, + refreshMetrics: selection.refreshMetrics, + refreshRatings: selection.refreshRatings, + refreshReviews: selection.refreshReviews + )) + } + + if selection.refreshPricing { + requests.append(try makeRefreshRequest( + for: app, + storefrontSelection: storefrontSelection, + trackIdentityKeys: [], + workspace: .pricing, + trigger: "manual_all_selected", + refreshKeywords: false, + refreshMetrics: false, + refreshRatings: false, + refreshReviews: false + )) + } + + return requests + } + } + private func trackedAppsForRefreshAll() -> [TrackedApp] { let activeAppStoreID = appStoreID return trackedApps.sorted { lhs, rhs in @@ -526,47 +654,175 @@ struct AppDetailView: View { private func makeRefreshRequest( for app: AppDetailRefreshAppSnapshot, + storefrontSelection: AppDetailRefreshStorefrontSelection, trackIdentityKeys: [String], activeWorkspaceView: AppDetailWorkspaceView, trigger: String ) throws -> AppDetailRefreshRequest { - let storefrontSelection: AppDetailRefreshStorefrontSelection - switch selectedStorefrontFilter { - case .all: - let codes = try services.storefrontCatalog.bundledStorefronts() - .map { $0.code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - storefrontSelection = .all(codes: Array(Set(codes)).sorted()) - case .storefront(let code, _): - storefrontSelection = .storefront(code: code) - } - let workspace: AppDetailRefreshWorkspace switch activeWorkspaceView { case .keywords: workspace = .keywords + case .competitors: + workspace = .keywords case .ratings: workspace = .ratings + case .pricing: + workspace = .pricing } + return try makeRefreshRequest( + for: app, + storefrontSelection: storefrontSelection, + trackIdentityKeys: trackIdentityKeys, + workspace: workspace, + trigger: trigger, + refreshKeywords: workspace != .pricing, + refreshMetrics: workspace != .pricing, + refreshRatings: workspace != .pricing, + refreshReviews: workspace != .pricing + ) + } + + private func makeRefreshRequest( + for app: AppDetailRefreshAppSnapshot, + storefrontSelection: AppDetailRefreshStorefrontSelection, + trackIdentityKeys: [String], + workspace: AppDetailRefreshWorkspace, + trigger: String, + refreshKeywords: Bool, + refreshMetrics: Bool, + refreshRatings: Bool, + refreshReviews: Bool + ) throws -> AppDetailRefreshRequest { return AppDetailRefreshRequest( app: app, workspace: workspace, storefrontSelection: storefrontSelection, trackIdentityKeys: trackIdentityKeys, trigger: trigger, + refreshKeywords: refreshKeywords, + refreshMetrics: refreshMetrics, + refreshRatings: refreshRatings, + refreshReviews: refreshReviews, + pricingStorefronts: workspace == .pricing ? try allPricingStorefrontCodes() : [], popularityContextAppStoreID: services.settingsStore.popularityContextAppStoreID, appleAdsWebSession: services.appleAdsWebSessionStore.session, appStoreConnectCredentials: services.appStoreConnectCredentialStore.credentials ) } + private func makeRefreshStorefrontSelection() throws -> AppDetailRefreshStorefrontSelection { + let storefrontSelection: AppDetailRefreshStorefrontSelection + switch selectedStorefrontFilter { + case .all: + let codes = try services.storefrontCatalog.bundledStorefronts() + .map { $0.code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + storefrontSelection = .all(codes: Array(Set(codes)).sorted()) + case .storefront(let code, _): + storefrontSelection = .storefront(code: code) + } + return storefrontSelection + } + + private func allPricingStorefrontCodes() throws -> [String] { + let codes = try services.storefrontCatalog.bundledStorefronts() + .map { $0.code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + return Array(Set(codes + ["us"])).sorted() + } + + private func trackedKeywordsForRefresh( + app: TrackedApp, + storefrontSelection: AppDetailRefreshStorefrontSelection, + platformFilter: PlatformFilter + ) throws -> [TrackedAppKeyword] { + try ensureGlobalKeywordTracks( + for: app, + storefrontSelection: storefrontSelection, + platformFilter: platformFilter + ) + + return try fetchTrackedKeywords(for: app.appStoreID, platformFilter: platformFilter) + .filter { matchesStorefrontSelection($0.storefront, selection: storefrontSelection) } + } + + private func ensureGlobalKeywordTracks( + for app: TrackedApp, + storefrontSelection: AppDetailRefreshStorefrontSelection, + platformFilter: PlatformFilter + ) throws { + let templates = globalKeywordTemplates.filter { + matchesStorefrontSelection($0.storefront, selection: storefrontSelection) + && platformFilter.matches($0.platform) + } + guard !templates.isEmpty else { return } + + var existingKeys = Set( + try fetchTrackedKeywords(for: app.appStoreID) + .map { keywordDuplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) } + ) + var insertedAny = false + + for template in templates { + let duplicateKey = keywordDuplicateKey( + term: template.term, + storefront: template.storefront, + platform: template.platform + ) + guard existingKeys.insert(duplicateKey).inserted else { continue } + + let query = try KeywordQuery.fetchOrInsert( + term: template.term, + storefront: template.storefront, + platform: template.platform, + in: modelContext + ) + let track = TrackedAppKeyword( + term: template.term, + storefront: template.storefront, + platform: template.platform, + trackedApp: app, + query: query + ) + track.notes = "Added from global keyword list." + app.keywordTracks.append(track) + modelContext.insert(track) + insertedAny = true + } + + if insertedAny { + try modelContext.save() + keywordRefreshToken += 1 + } + } + + private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { + GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + } + + private func matchesStorefrontSelection( + _ storefront: String, + selection: AppDetailRefreshStorefrontSelection + ) -> Bool { + let normalizedStorefront = storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch selection { + case .all(let codes): + return Set(codes.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) + .contains(normalizedStorefront) + case .storefront(let code): + return code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalizedStorefront + } + } + private func setErrorMessage(_ message: String) { errorMessage = message } private var keywordExportFilename: String { - let sanitizedName = appName + let sanitizedName = + appName .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } .joined(separator: "-") @@ -576,7 +832,8 @@ struct AppDetailView: View { } private var keywordHistoryExportFilename: String { - let sanitizedName = appName + let sanitizedName = + appName .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } .joined(separator: "-") @@ -592,7 +849,8 @@ struct AppDetailView: View { } private var ratingsExportFilename: String { - let sanitizedName = appName + let sanitizedName = + appName .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } .joined(separator: "-") @@ -619,7 +877,8 @@ struct AppDetailView: View { storeDomain: track.storefront, store: storefrontTitle(for: track.storefront), note: track.notes, - lastUpdate: TrackedKeywordCSVFormat.string(from: track.lastRefreshAt ?? snapshot?.searchedAt), + lastUpdate: TrackedKeywordCSVFormat.string( + from: track.lastRefreshAt ?? snapshot?.searchedAt), ranking: snapshot?.rank.map(String.init) ?? "1000", change: changeText(for: track), popularity: metrics?.popularityScore.map(String.init) ?? "", @@ -638,7 +897,8 @@ struct AppDetailView: View { for: tracks.map(\.queryKey), in: modelContext ) - let rows = tracks + let rows = + tracks .filter { matchesExportStorefront($0) } .filter { matchesExportPlatform($0) } .filter { matchesExportSearch($0) } @@ -672,7 +932,8 @@ struct AppDetailView: View { isExportingCSV = true services.analyticsService.capture(.csvExported(type: "keywords")) } catch { - transferAlert = TrackedKeywordTransferAlert(title: "Export Failed", message: error.localizedDescription) + transferAlert = TrackedKeywordTransferAlert( + title: "Export Failed", message: error.localizedDescription) } } @@ -683,7 +944,8 @@ struct AppDetailView: View { isExportingCSV = true services.analyticsService.capture(.csvExported(type: "keyword_history")) } catch { - transferAlert = TrackedKeywordTransferAlert(title: "Export Failed", message: error.localizedDescription) + transferAlert = TrackedKeywordTransferAlert( + title: "Export Failed", message: error.localizedDescription) } } @@ -695,7 +957,8 @@ struct AppDetailView: View { isExportingCSV = true services.analyticsService.capture(.csvExported(type: "ratings")) } catch { - transferAlert = TrackedKeywordTransferAlert(title: "Export Failed", message: error.localizedDescription) + transferAlert = TrackedKeywordTransferAlert( + title: "Export Failed", message: error.localizedDescription) } } } @@ -727,7 +990,9 @@ struct AppDetailView: View { snapshots.count > 1, let periodStartRank = snapshots.first?.rank, let periodEndRank = snapshots.last?.rank, - matchesExportValue(periodStartRank - periodEndRank, in: keywordWorkspaceState.changeFilterRange, configuration: .change) + matchesExportValue( + periodStartRank - periodEndRank, in: keywordWorkspaceState.changeFilterRange, + configuration: .change) else { return [] } @@ -790,9 +1055,16 @@ struct AppDetailView: View { return track.term.localizedCaseInsensitiveContains(trimmedSearch) } - private func matchesExportMetrics(_ track: TrackedAppKeyword, metrics: KeywordDailyMetric?) -> Bool { - guard matchesExportValue(metrics?.popularityScore, in: keywordWorkspaceState.popularityFilterRange, configuration: .popularity), - matchesExportValue(metrics?.difficultyScore, in: keywordWorkspaceState.difficultyFilterRange, configuration: .difficulty) + private func matchesExportMetrics(_ track: TrackedAppKeyword, metrics: KeywordDailyMetric?) + -> Bool + { + guard + matchesExportValue( + metrics?.popularityScore, in: keywordWorkspaceState.popularityFilterRange, + configuration: .popularity), + matchesExportValue( + metrics?.difficultyScore, in: keywordWorkspaceState.difficultyFilterRange, + configuration: .difficulty) else { return false } @@ -802,7 +1074,8 @@ struct AppDetailView: View { return MetricFilterRange.position.isDefault(keywordWorkspaceState.positionFilterRange) } - return matchesExportValue(latestRank, in: keywordWorkspaceState.positionFilterRange, configuration: .position) + return matchesExportValue( + latestRank, in: keywordWorkspaceState.positionFilterRange, configuration: .position) } private func matchesExportChangedOnly(_ track: TrackedAppKeyword) -> Bool { @@ -818,7 +1091,9 @@ struct AppDetailView: View { return firstRank != latestRank } - private func matchesExportValue(_ value: Int?, in range: ClosedRange, configuration: MetricFilterRange) -> Bool { + private func matchesExportValue( + _ value: Int?, in range: ClosedRange, configuration: MetricFilterRange + ) -> Bool { if configuration.isDefault(range) { return true } @@ -871,20 +1146,24 @@ struct AppDetailView: View { let data = try Data(contentsOf: url) let csv = String(decoding: data, as: UTF8.self) #if DEBUG - print(TrackedKeywordCSVFormat.debugImportSummary( - csv: csv, - fileName: url.lastPathComponent, - filePath: url.path, - byteCount: data.count, - didAccessSecurityScopedResource: didAccess - )) + print( + TrackedKeywordCSVFormat.debugImportSummary( + csv: csv, + fileName: url.lastPathComponent, + filePath: url.path, + byteCount: data.count, + didAccessSecurityScopedResource: didAccess + )) #endif let rows = try TrackedKeywordCSVFormat.decode(csv) let summary = importRows(rows) #if DEBUG - print("[CSVImportDebug] importResult inserted=\(summary.insertedCount) skippedExisting=\(summary.skippedExistingCount) skippedDuplicates=\(summary.skippedDuplicateCount) skippedInvalid=\(summary.skippedInvalidCount) createdApps=\(summary.createdAppCount) importedApps=\(summary.importedAppIDs.count)") + print( + "[CSVImportDebug] importResult inserted=\(summary.insertedCount) skippedExisting=\(summary.skippedExistingCount) skippedDuplicates=\(summary.skippedDuplicateCount) skippedInvalid=\(summary.skippedInvalidCount) createdApps=\(summary.createdAppCount) importedApps=\(summary.importedAppIDs.count)" + ) #endif - services.analyticsService.capture(.csvImported(rowCount: summary.insertedCount, result: "success")) + services.analyticsService.capture( + .csvImported(rowCount: summary.insertedCount, result: "success")) refreshImportedTracksInBackground(summary.importedTracks) if summary.insertedCount == 0 { @@ -895,15 +1174,17 @@ struct AppDetailView: View { } else { transferAlert = TrackedKeywordTransferAlert( title: "Import Complete", - message: "Imported \(summary.insertedCount) keyword track\(summary.insertedCount == 1 ? "" : "s") across \(summary.importedAppIDs.count) app\(summary.importedAppIDs.count == 1 ? "" : "s"). Created \(summary.createdAppCount) app\(summary.createdAppCount == 1 ? "" : "s"). \(summary.skippedRowsMessage)" + message: + "Imported \(summary.insertedCount) keyword track\(summary.insertedCount == 1 ? "" : "s") across \(summary.importedAppIDs.count) app\(summary.importedAppIDs.count == 1 ? "" : "s"). Created \(summary.createdAppCount) app\(summary.createdAppCount == 1 ? "" : "s"). \(summary.skippedRowsMessage)" ) } } catch { #if DEBUG - print("[CSVImportDebug] importFailed error=\(error.localizedDescription)") + print("[CSVImportDebug] importFailed error=\(error.localizedDescription)") #endif services.analyticsService.capture(.csvImported(rowCount: 0, result: "failure")) - transferAlert = TrackedKeywordTransferAlert(title: "Import Failed", message: error.localizedDescription) + transferAlert = TrackedKeywordTransferAlert( + title: "Import Failed", message: error.localizedDescription) } } @@ -923,14 +1204,16 @@ struct AppDetailView: View { ) } ) - trackedAppsByAppStoreID = Dictionary(uniqueKeysWithValues: try fetchTrackedApps().map { ($0.appStoreID, $0) }) + trackedAppsByAppStoreID = Dictionary( + uniqueKeysWithValues: try fetchTrackedApps().map { ($0.appStoreID, $0) }) } catch { setErrorMessage(OpenASOError.map(error).localizedDescription) var summary = TrackedKeywordCSVImportSummary() summary.failedRowCount = rows.count return summary } - var nextSidebarSortOrder = trackedAppsByAppStoreID.values + var nextSidebarSortOrder = + trackedAppsByAppStoreID.values .filter { $0.folder == nil } .map(\.sidebarSortOrder) .max() @@ -941,14 +1224,18 @@ struct AppDetailView: View { for row in rows { let keyword = row.keyword.trimmingCharacters(in: .whitespacesAndNewlines) - let storefront = row.storeDomain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let storefront = row.storeDomain.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() guard !keyword.isEmpty, !storefront.isEmpty else { summary.skippedInvalidCount += 1 continue } - let rowPlatform = AppPlatform(rawValue: row.platform.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) - guard let rowAppStoreID = importAppStoreID(from: row.appID, defaultAppStoreID: appStoreID) else { + let rowPlatform = AppPlatform( + rawValue: row.platform.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + guard + let rowAppStoreID = importAppStoreID(from: row.appID, defaultAppStoreID: appStoreID) + else { summary.skippedInvalidCount += 1 continue } @@ -972,7 +1259,8 @@ struct AppDetailView: View { } do { - let queryKey = KeywordQuery.makeQueryKey(term: keyword, storefront: storefront, platform: platform) + let queryKey = KeywordQuery.makeQueryKey( + term: keyword, storefront: storefront, platform: platform) let query: KeywordQuery if let cachedQuery = queriesByKey[queryKey] { query = cachedQuery @@ -1098,7 +1386,9 @@ struct AppDetailView: View { } return orderedAppStoreIDs.compactMap { appStoreID in - guard let tracks = tracksByAppStoreID[appStoreID], let trackedApp = tracks.first?.trackedApp else { + guard let tracks = tracksByAppStoreID[appStoreID], + let trackedApp = tracks.first?.trackedApp + else { return nil } @@ -1125,12 +1415,14 @@ struct AppDetailView: View { } } - private func importDuplicateKey(appStoreID: Int64, term: String, storefront: String, platform: AppPlatform) -> String { + private func importDuplicateKey( + appStoreID: Int64, term: String, storefront: String, platform: AppPlatform + ) -> String { [ String(appStoreID), term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - platform.rawValue + platform.rawValue, ].joined(separator: "::") } @@ -1156,7 +1448,8 @@ struct AppDetailView: View { sellerName: nil, defaultPlatform: defaultPlatform ) - let storeApp = try services.appCatalogService.upsertStoreApp(from: resolvedApp, in: modelContext) + let storeApp = try services.appCatalogService.upsertStoreApp( + from: resolvedApp, in: modelContext) let trackedApp = TrackedApp( appStoreID: appStoreID, @@ -1164,7 +1457,8 @@ struct AppDetailView: View { sidebarSortOrder: sidebarSortOrder ) modelContext.insert(trackedApp) - services.analyticsService.capture(.trackedAppAdded(platform: defaultPlatform, source: "csv_import")) + services.analyticsService.capture( + .trackedAppAdded(platform: defaultPlatform, source: "csv_import")) return trackedApp } @@ -1175,7 +1469,9 @@ struct AppDetailView: View { } } - private func applyImportedValues(from row: TrackedKeywordCSVRow, to importedTrack: TrackedAppKeyword) { + private func applyImportedValues( + from row: TrackedKeywordCSVRow, to importedTrack: TrackedAppKeyword + ) { if let appsInRanking = Int(row.appsInRanking) { importedTrack.rankingAppCount = appsInRanking } @@ -1212,16 +1508,18 @@ struct AppDetailView: View { if let cachedMetrics = metricsByQueryKey[queryKey] { metrics = cachedMetrics } else { - metrics = existingMetrics(for: importedTrack) ?? KeywordDailyMetric( - queryKey: queryKey, - keyword: importedTrack.term, - storefront: importedTrack.storefront, - platform: importedTrack.platform, - popularityScore: nil, - difficultyScore: nil, - source: .appleAdsPopularity, - updatedAt: .distantPast - ) + metrics = + existingMetrics(for: importedTrack) + ?? KeywordDailyMetric( + queryKey: queryKey, + keyword: importedTrack.term, + storefront: importedTrack.storefront, + platform: importedTrack.platform, + popularityScore: nil, + difficultyScore: nil, + source: .appleAdsPopularity, + updatedAt: .distantPast + ) metricsByQueryKey[queryKey] = metrics } @@ -1249,10 +1547,13 @@ struct AppDetailView: View { } private func fetchTrackedKeywords() throws -> [TrackedAppKeyword] { - try fetchTrackedKeywords(for: appStoreID, platformFilter: keywordWorkspaceState.selectedPlatformFilter) + try fetchTrackedKeywords( + for: appStoreID, platformFilter: keywordWorkspaceState.selectedPlatformFilter) } - private func fetchTrackedKeywords(for appStoreID: Int64, platformFilter: PlatformFilter = .all) throws -> [TrackedAppKeyword] { + private func fetchTrackedKeywords(for appStoreID: Int64, platformFilter: PlatformFilter = .all) + throws -> [TrackedAppKeyword] + { let descriptor = FetchDescriptor( predicate: #Predicate { track in track.appStoreID == appStoreID @@ -1260,7 +1561,7 @@ struct AppDetailView: View { sortBy: [ SortDescriptor(\TrackedAppKeyword.term, order: .forward), SortDescriptor(\TrackedAppKeyword.storefront, order: .forward), - SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward) + SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward), ] ) return try modelContext.fetch(descriptor) @@ -1270,15 +1571,20 @@ struct AppDetailView: View { private func existingKeywordDuplicateKeys() throws -> Set { Set( try fetchTrackedKeywords() - .map { keywordDuplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) } + .map { + keywordDuplicateKey( + term: $0.term, storefront: $0.storefront, platform: $0.platform) + } ) } - private func keywordDuplicateKey(term: String, storefront: String, platform: AppPlatform) -> String { + private func keywordDuplicateKey(term: String, storefront: String, platform: AppPlatform) + -> String + { [ term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - platform.rawValue + platform.rawValue, ].joined(separator: "::") } @@ -1288,7 +1594,7 @@ struct AppDetailView: View { SortDescriptor(\TrackedAppKeyword.appStoreID, order: .forward), SortDescriptor(\TrackedAppKeyword.term, order: .forward), SortDescriptor(\TrackedAppKeyword.storefront, order: .forward), - SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward) + SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward), ] ) return try modelContext.fetch(descriptor) @@ -1315,3 +1621,318 @@ struct AppDetailView: View { return "\(storefront.flagEmoji) \(storefront.name)" } } + +private struct CompetitorComparisonView: View { + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\KeywordAppRanking.observedAt, order: .reverse)]) + private var rankingItems: [KeywordAppRanking] + + let trackedApp: TrackedApp + let trackedApps: [TrackedApp] + let selectedStorefrontFilter: StorefrontFilter + let searchText: String + let reportError: (String) -> Void + let didAddKeyword: () -> Void + + @State private var selectedCompetitorIDs = Set() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + competitorPicker + .padding(.horizontal, 24) + .padding(.vertical, 14) + + Divider() + + if selectedCompetitorIDs.isEmpty { + ContentUnavailableView( + "Select Competitors", + systemImage: "person.2", + description: Text( + "Choose one or more tracked competitor apps to compare rankings.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if opportunities.isEmpty { + ContentUnavailableView( + "No Opportunities Found", + systemImage: "magnifyingglass", + description: Text( + "Refresh keyword rankings or change the selected competitors and country.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(opportunities) { opportunity in + CompetitorOpportunityRow( + opportunity: opportunity, + addAction: { + addKeyword(from: opportunity) + } + ) + } + .listStyle(.plain) + } + } + .onAppear(perform: selectDefaultCompetitors) + } + + private var competitorPicker: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Competitor View") + .font(.headline) + Spacer() + Text("\(opportunities.count.formatted()) opportunities") + .foregroundStyle(.secondary) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(competitorApps) { app in + Toggle(isOn: competitorBinding(for: app.appStoreID)) { + Text(app.name) + .lineLimit(1) + } + .toggleStyle(.button) + } + } + } + } + } + + private var competitorApps: [TrackedApp] { + trackedApps + .filter { $0.appStoreID != trackedApp.appStoreID } + .sorted { lhs, rhs in + lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private var opportunities: [CompetitorKeywordOpportunity] { + let competitorIDs = selectedCompetitorIDs + guard !competitorIDs.isEmpty else { return [] } + + let targetAppStoreID = trackedApp.appStoreID + let relevantItems = rankingItems.filter { item in + (item.appStoreID == targetAppStoreID || competitorIDs.contains(item.appStoreID)) + && selectedStorefrontFilter.matches(storefront: item.storefront) + } + + let latestItemsByQueryKey = latestRankingItemsByQueryKey(from: relevantItems) + let trackedKeywordKeys = Set( + trackedApp.keywordTracks.map { + duplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) + } + ) + let normalizedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + return latestItemsByQueryKey.values.compactMap { items in + guard let firstItem = items.first else { return nil } + let competitorItems = items.filter { competitorIDs.contains($0.appStoreID) } + guard let bestCompetitor = competitorItems.min(by: { $0.position < $1.position }) else { + return nil + } + + let targetItem = items.first { $0.appStoreID == targetAppStoreID } + guard targetItem == nil || bestCompetitor.position < (targetItem?.position ?? Int.max) + else { + return nil + } + + let keyword = firstItem.observation.keyword + let platform = firstItem.platform + let duplicateKey = duplicateKey( + term: keyword, storefront: firstItem.storefront, platform: platform) + let opportunity = CompetitorKeywordOpportunity( + keyword: keyword, + storefront: firstItem.storefront, + platform: platform, + competitorName: bestCompetitor.name, + competitorPosition: bestCompetitor.position, + yourPosition: targetItem?.position, + observedAt: firstItem.observedAt, + isAlreadyTracked: trackedKeywordKeys.contains(duplicateKey) + ) + + guard + normalizedSearch.isEmpty + || opportunity.keyword.localizedCaseInsensitiveContains(normalizedSearch) + || opportunity.competitorName.localizedCaseInsensitiveContains(normalizedSearch) + || opportunity.storefront.localizedCaseInsensitiveContains(normalizedSearch) + else { + return nil + } + return opportunity + } + .sorted() + .prefix(300) + .map { $0 } + } + + private func latestRankingItemsByQueryKey(from items: [KeywordAppRanking]) -> [String: + [KeywordAppRanking]] + { + let itemsByCrawlKey = Dictionary(grouping: items, by: \.crawlKey) + var latestByQueryKey: [String: [KeywordAppRanking]] = [:] + + for crawlItems in itemsByCrawlKey.values { + guard let firstItem = crawlItems.first else { continue } + let queryKey = firstItem.queryKey + let existingDate = latestByQueryKey[queryKey]?.first?.observedAt ?? .distantPast + if firstItem.observedAt > existingDate { + latestByQueryKey[queryKey] = crawlItems + } + } + + return latestByQueryKey + } + + private func selectDefaultCompetitors() { + guard selectedCompetitorIDs.isEmpty else { return } + selectedCompetitorIDs = Set(competitorApps.prefix(3).map(\.appStoreID)) + } + + private func competitorBinding(for appStoreID: Int64) -> Binding { + Binding( + get: { selectedCompetitorIDs.contains(appStoreID) }, + set: { isSelected in + if isSelected { + selectedCompetitorIDs.insert(appStoreID) + } else { + selectedCompetitorIDs.remove(appStoreID) + } + } + ) + } + + private func addKeyword(from opportunity: CompetitorKeywordOpportunity) { + guard !opportunity.isAlreadyTracked else { return } + + do { + let query = try KeywordQuery.fetchOrInsert( + term: opportunity.keyword, + storefront: opportunity.storefront, + platform: opportunity.platform, + in: modelContext + ) + let track = TrackedAppKeyword( + term: opportunity.keyword, + storefront: opportunity.storefront, + platform: opportunity.platform, + trackedApp: trackedApp, + query: query + ) + trackedApp.keywordTracks.append(track) + modelContext.insert(track) + try modelContext.save() + didAddKeyword() + } catch { + reportError(OpenASOError.map(error).localizedDescription) + } + } + + private func duplicateKey(term: String, storefront: String, platform: AppPlatform) -> String { + [ + term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform.rawValue, + ].joined(separator: "::") + } +} + +private struct CompetitorOpportunityRow: View { + let opportunity: CompetitorKeywordOpportunity + let addAction: () -> Void + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(opportunity.keyword) + .font(.headline) + Text(opportunity.storefront.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + Text(opportunity.platform.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Text("\(opportunity.competitorName) ranks #\(opportunity.competitorPosition)") + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(opportunity.yourRankTitle) + .font(.body.monospacedDigit()) + Text(opportunity.observedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(width: 150, alignment: .trailing) + + Button(opportunity.isAlreadyTracked ? "Tracked" : "Add Keyword", action: addAction) + .disabled(opportunity.isAlreadyTracked) + .buttonStyle(.borderedProminent) + .frame(width: 120, alignment: .trailing) + } + .padding(.vertical, 8) + } +} + +private struct CompetitorKeywordOpportunity: Identifiable, Comparable { + let keyword: String + let storefront: String + let platform: AppPlatform + let competitorName: String + let competitorPosition: Int + let yourPosition: Int? + let observedAt: Date + let isAlreadyTracked: Bool + + var id: String { + [ + keyword.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + storefront, + platform.rawValue, + competitorName, + ].joined(separator: "::") + } + + var yourRankTitle: String { + if let yourPosition { + return "You #\(yourPosition)" + } + return "Not ranking" + } + + static func < (lhs: CompetitorKeywordOpportunity, rhs: CompetitorKeywordOpportunity) -> Bool { + if lhs.isAlreadyTracked != rhs.isAlreadyTracked { + return !lhs.isAlreadyTracked + } + if lhs.yourPosition == nil, rhs.yourPosition != nil { + return true + } + if lhs.yourPosition != nil, rhs.yourPosition == nil { + return false + } + if lhs.competitorPosition != rhs.competitorPosition { + return lhs.competitorPosition < rhs.competitorPosition + } + return lhs.keyword.localizedStandardCompare(rhs.keyword) == .orderedAscending + } +} + +extension StorefrontFilter { + fileprivate func matches(storefront: String) -> Bool { + switch self { + case .all: + return true + case .storefront(let code, _): + return code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + == storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + } +} diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index 036ce42..9e91a23 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -10,6 +10,7 @@ struct AddKeywordsSheet: View { private var storefronts: [Storefront] @Query private var trackedKeywords: [TrackedAppKeyword] + @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" let trackedApp: TrackedApp let externalRefreshInProgress: Bool @@ -18,6 +19,8 @@ struct AddKeywordsSheet: View { @State private var keywordInput = "" @State private var selectedPlatform: AppPlatform @State private var selectedStorefrontCodes: Set = ["us"] + @State private var selectedGlobalKeywordIDs = Set() + @State private var savesInputToGlobalKeywords = true @State private var storefrontSearchText = "" @State private var errorMessage: String? @State private var statusMessage: String? @@ -41,11 +44,13 @@ struct AddKeywordsSheet: View { sort: [ SortDescriptor(\TrackedAppKeyword.storefront, order: .forward), SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward), - SortDescriptor(\TrackedAppKeyword.term, order: .forward) + SortDescriptor(\TrackedAppKeyword.term, order: .forward), ] ) _selectedPlatform = State(initialValue: trackedApp.defaultPlatform) - _selectedStorefrontCodes = State(initialValue: [Self.defaultStorefrontCode(from: initialStorefrontCode)]) + _selectedStorefrontCodes = State(initialValue: [ + Self.defaultStorefrontCode(from: initialStorefrontCode) + ]) } var body: some View { @@ -54,8 +59,10 @@ struct AddKeywordsSheet: View { .font(.title2) .bold() - Text("Paste one keyword per line or separate them with commas. Each keyword will be tracked for every selected country.") - .foregroundStyle(.secondary) + Text( + "Paste one keyword per line or separate them with commas. Each keyword will be tracked for every selected country. Reusable keywords can be applied without copy-paste." + ) + .foregroundStyle(.secondary) Picker("Device", selection: $selectedPlatform) { ForEach(AppPlatform.allCases) { platform in @@ -68,13 +75,20 @@ struct AddKeywordsSheet: View { TextEditor(text: $keywordInput) .font(.body.monospaced()) - .frame(minHeight: 180) + .frame(minHeight: 140) .disabled(isInputLocked) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) } + Toggle( + "Save pasted keywords to reusable global list", isOn: $savesInputToGlobalKeywords + ) + .disabled(isInputLocked) + + reusableKeywordsSection + Text("Countries") .font(.headline) @@ -102,8 +116,10 @@ struct AddKeywordsSheet: View { Text(statusMessage) .foregroundStyle(.secondary) } else if isRefreshInProgress { - Text("A refresh is running. Add Tracks will queue this change in the background and close this sheet.") - .foregroundStyle(.secondary) + Text( + "A refresh is running. Add Tracks will queue this change in the background and close this sheet." + ) + .foregroundStyle(.secondary) } HStack { @@ -119,7 +135,50 @@ struct AddKeywordsSheet: View { } } .padding(24) - .frame(minWidth: 720, minHeight: 720) + .frame(minWidth: 780, minHeight: 780) + } + + @ViewBuilder + private var reusableKeywordsSection: some View { + let keywords = reusableKeywordsForSelection + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Reusable Keywords") + .font(.headline) + Spacer() + if !keywords.isEmpty { + Button("Select All") { + selectedGlobalKeywordIDs.formUnion(keywords.map(\.id)) + } + .disabled(isInputLocked) + Button("Clear") { + selectedGlobalKeywordIDs.removeAll() + } + .disabled(isInputLocked || selectedGlobalKeywordIDs.isEmpty) + } + } + + if keywords.isEmpty { + Text("No reusable keywords match the selected device and countries yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + List(keywords) { keyword in + Toggle(isOn: reusableKeywordBinding(for: keyword.id)) { + HStack(spacing: 10) { + Text(keyword.term) + Spacer() + Text(keyword.storefront.uppercased()) + .foregroundStyle(.secondary) + Text(keyword.platform.displayName) + .foregroundStyle(.secondary) + } + } + } + .frame(minHeight: 110, maxHeight: 150) + .disabled(isInputLocked) + } + } } private var filteredStorefronts: [Storefront] { @@ -141,12 +200,33 @@ struct AddKeywordsSheet: View { } private var keywordCountsByStorefront: [String: Int] { - Dictionary(grouping: trackedKeywords.filter { $0.platform == selectedPlatform }, by: \.storefront) - .mapValues(\.count) + Dictionary( + grouping: trackedKeywords.filter { $0.platform == selectedPlatform }, by: \.storefront + ) + .mapValues(\.count) + } + + private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { + GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + } + + private var reusableKeywordsForSelection: [GlobalTrackedKeywordTemplate] { + globalKeywordTemplates + .filter { template in + template.platform == selectedPlatform + && selectedStorefrontCodes.contains(template.storefront) + } + .sorted { lhs, rhs in + if lhs.storefront == rhs.storefront { + return lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending + } + return lhs.storefront < rhs.storefront + } } private func keywordCount(for storefrontCode: String) -> Int { - keywordCountsByStorefront[storefrontCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), default: 0] + keywordCountsByStorefront[ + storefrontCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), default: 0] } private func storefrontBinding(for code: String) -> Binding { @@ -157,6 +237,21 @@ struct AddKeywordsSheet: View { selectedStorefrontCodes.insert(code) } else { selectedStorefrontCodes.remove(code) + selectedGlobalKeywordIDs.subtract( + globalKeywordTemplates.filter { $0.storefront == code }.map(\.id)) + } + } + ) + } + + private func reusableKeywordBinding(for id: String) -> Binding { + Binding( + get: { selectedGlobalKeywordIDs.contains(id) }, + set: { isSelected in + if isSelected { + selectedGlobalKeywordIDs.insert(id) + } else { + selectedGlobalKeywordIDs.remove(id) } } ) @@ -185,7 +280,8 @@ struct AddKeywordsSheet: View { switch refresh.phase { case .completed, .failed: return false - case .preparing, .refreshingKeywords, .refreshingMetrics, .refreshingRatings, .refreshingReviews, .finishing: + case .preparing, .refreshingKeywords, .refreshingMetrics, .refreshingRatings, + .refreshingReviews, .refreshingPricing, .finishing: return true } } @@ -195,8 +291,8 @@ struct AddKeywordsSheet: View { return } - let keywords = parsedKeywords - guard !keywords.isEmpty else { + let candidates = keywordCandidates + guard !candidates.isEmpty else { errorMessage = "Enter at least one keyword." return } @@ -209,21 +305,28 @@ struct AddKeywordsSheet: View { let storefrontCodes = selectedStorefrontCodes let platform = selectedPlatform if isRefreshInProgress { - queueKeywordAdd(KeywordAddRequest( - keywords: keywords, - storefrontCodes: storefrontCodes, - platform: platform - )) + queueKeywordAdd( + KeywordAddRequest( + candidates: candidates + )) errorMessage = nil - statusMessage = "Queued. These keywords will be added after the current refresh finishes." + statusMessage = + "Queued. These keywords will be added after the current refresh finishes." dismiss() return } - addTracks(keywords: keywords, storefrontCodes: storefrontCodes, platform: platform) + addTracks( + candidates: candidates, typedKeywords: parsedKeywords, storefrontCodes: storefrontCodes, + platform: platform) } - private func addTracks(keywords: [String], storefrontCodes: Set, platform: AppPlatform) { + private func addTracks( + candidates: [KeywordAddCandidate], + typedKeywords: [String], + storefrontCodes: Set, + platform: AppPlatform + ) { isSubmitting = true errorMessage = nil statusMessage = nil @@ -240,40 +343,40 @@ struct AddKeywordsSheet: View { var insertedCount = 0 var insertedTracks: [TrackedAppKeyword] = [] - for storefrontCode in storefrontCodes.sorted() { - for keyword in keywords { - let identityKey = duplicateKey(term: keyword, storefront: storefrontCode, platform: platform) - - guard !mutableExistingKeys.contains(identityKey) else { continue } + for candidate in candidates { + let identityKey = duplicateKey( + term: candidate.keyword, storefront: candidate.storefrontCode, + platform: candidate.platform) - let query: KeywordQuery - do { - query = try KeywordQuery.fetchOrInsert( - term: keyword, - storefront: storefrontCode, - platform: platform, - in: modelContext - ) - } catch { - errorMessage = OpenASOError.map(error).localizedDescription - isSubmitting = false - return - } + guard !mutableExistingKeys.contains(identityKey) else { continue } - let track = TrackedAppKeyword( - term: keyword, - storefront: storefrontCode, - platform: platform, - trackedApp: trackedApp, - query: query + let query: KeywordQuery + do { + query = try KeywordQuery.fetchOrInsert( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform, + in: modelContext ) - - trackedApp.keywordTracks.append(track) - modelContext.insert(track) - mutableExistingKeys.insert(identityKey) - insertedCount += 1 - insertedTracks.append(track) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + isSubmitting = false + return } + + let track = TrackedAppKeyword( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform, + trackedApp: trackedApp, + query: query + ) + + trackedApp.keywordTracks.append(track) + modelContext.insert(track) + mutableExistingKeys.insert(identityKey) + insertedCount += 1 + insertedTracks.append(track) } guard insertedCount > 0 else { @@ -282,12 +385,18 @@ struct AddKeywordsSheet: View { return } + if savesInputToGlobalKeywords, !typedKeywords.isEmpty { + persistGlobalKeywords( + keywords: typedKeywords, storefrontCodes: storefrontCodes, platform: platform) + } + do { try modelContext.save() - services.analyticsService.capture(.keywordAdded( - keywordCount: keywords.count, - storefrontCount: storefrontCodes.count - )) + services.analyticsService.capture( + .keywordAdded( + keywordCount: Set(candidates.map(\.keyword.normalizedKeywordKey)).count, + storefrontCount: storefrontCodes.count + )) dismiss() guard let refreshService = services.appDetailRefreshService else { @@ -332,7 +441,7 @@ struct AddKeywordsSheet: View { [ term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - platform.rawValue + platform.rawValue, ].joined(separator: "::") } @@ -345,13 +454,16 @@ struct AddKeywordsSheet: View { ) return Set( try modelContext.fetch(descriptor) - .map { duplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) } + .map { + duplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) + } ) } private var parsedKeywords: [String] { let separators = CharacterSet(charactersIn: ",\n") - let parts = keywordInput + let parts = + keywordInput .components(separatedBy: separators) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } @@ -364,6 +476,60 @@ struct AddKeywordsSheet: View { } } + private var selectedGlobalKeywords: [GlobalTrackedKeywordTemplate] { + globalKeywordTemplates.filter { selectedGlobalKeywordIDs.contains($0.id) } + } + + private var keywordCandidates: [KeywordAddCandidate] { + var seen = Set() + var candidates: [KeywordAddCandidate] = [] + + for storefrontCode in selectedStorefrontCodes.sorted() { + for keyword in parsedKeywords { + let candidate = KeywordAddCandidate( + keyword: keyword, + storefrontCode: storefrontCode, + platform: selectedPlatform + ) + guard seen.insert(candidate.identityKey).inserted else { continue } + candidates.append(candidate) + } + } + + for template in selectedGlobalKeywords { + let candidate = KeywordAddCandidate( + keyword: template.term, + storefrontCode: template.storefront, + platform: template.platform + ) + guard seen.insert(candidate.identityKey).inserted else { continue } + candidates.append(candidate) + } + + return candidates + } + + private func persistGlobalKeywords( + keywords: [String], storefrontCodes: Set, platform: AppPlatform + ) { + var templates = globalKeywordTemplates + var existingIDs = Set(templates.map(\.id)) + + for storefrontCode in storefrontCodes.sorted() { + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate( + term: keyword, + storefront: storefrontCode, + platform: platform + ) + guard existingIDs.insert(template.id).inserted else { continue } + templates.append(template) + } + } + + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) + } + private static func defaultStorefrontCode(from code: String?) -> String { let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard let normalized, !normalized.isEmpty else { @@ -374,13 +540,765 @@ struct AddKeywordsSheet: View { } struct KeywordAddRequest { - let keywords: [String] - let storefrontCodes: Set + let candidates: [KeywordAddCandidate] + + var keywords: [String] { + Array(Set(candidates.map(\.keyword))).sorted() + } + + var storefrontCodes: Set { + Set(candidates.map(\.storefrontCode)) + } +} + +struct KeywordAddCandidate: Hashable { + let keyword: String + let storefrontCode: String let platform: AppPlatform + + var identityKey: String { + [ + keyword.normalizedKeywordKey, + storefrontCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform.rawValue, + ].joined(separator: "::") + } +} + +struct GlobalTrackedKeywordTemplate: Codable, Identifiable, Hashable { + let term: String + let storefront: String + let platformRaw: String + let createdAt: Date + + init(term: String, storefront: String, platform: AppPlatform, createdAt: Date = .now) { + self.term = term.trimmingCharacters(in: .whitespacesAndNewlines) + self.storefront = storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self.platformRaw = platform.rawValue + self.createdAt = createdAt + } + + var id: String { + [ + term.normalizedKeywordKey, + storefront, + platformRaw, + ].joined(separator: "::") + } + + var platform: AppPlatform { + AppPlatform(rawValue: platformRaw) ?? .iphone + } + + static func decodeList(from json: String) -> [GlobalTrackedKeywordTemplate] { + guard let data = json.data(using: .utf8) else { + return [] + } + return (try? JSONDecoder().decode([GlobalTrackedKeywordTemplate].self, from: data)) ?? [] + } + + static func encodeList(_ templates: [GlobalTrackedKeywordTemplate]) -> String { + guard let data = try? JSONEncoder().encode(templates), + let json = String(data: data, encoding: .utf8) + else { + return "[]" + } + return json + } +} + +struct ManageKeywordListsSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Environment(AppServices.self) private var services + + @Query(sort: [SortDescriptor(\Storefront.name, order: .forward)]) + private var storefronts: [Storefront] + + @Query private var localKeywords: [TrackedAppKeyword] + @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" + + let trackedApp: TrackedApp + + @State private var selectedScope = KeywordListScope.local + @State private var editingEntry: KeywordListEditingEntry? + @State private var resetConfirmation: KeywordListResetConfirmation? + @State private var errorMessage: String? + + init(trackedApp: TrackedApp) { + self.trackedApp = trackedApp + + let appStoreID = trackedApp.appStoreID + _localKeywords = Query( + filter: #Predicate { track in + track.appStoreID == appStoreID + }, + sort: [ + SortDescriptor(\TrackedAppKeyword.storefront, order: .forward), + SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward), + SortDescriptor(\TrackedAppKeyword.term, order: .forward), + ] + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Keyword Lists") + .font(.title2) + .bold() + Spacer() + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + + Picker("List", selection: $selectedScope) { + ForEach(KeywordListScope.allCases) { scope in + Text(scope.title) + .tag(scope) + } + } + .pickerStyle(.segmented) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + switch selectedScope { + case .local: + localKeywordList + case .global: + globalKeywordList + } + } + .padding(24) + .frame(minWidth: 820, minHeight: 620) + .sheet(item: $editingEntry) { entry in + KeywordListEntryEditor( + title: entry.title, + draft: entry.draft, + allowsMultipleKeywords: entry.allowsMultipleKeywords, + storefronts: editorStorefronts(including: entry.draft.storefront) + ) { draft in + save(entry: entry, draft: draft) + } + } + .alert(item: $resetConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.buttonTitle)) { + reset(confirmation) + }, + secondaryButton: .cancel() + ) + } + } + + private var localKeywordList: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("\(localKeywords.count.formatted()) tracked keywords for \(trackedApp.name)") + .foregroundStyle(.secondary) + Spacer() + Button(role: .destructive) { + resetConfirmation = .local(appName: trackedApp.name, count: localKeywords.count) + } label: { + Label("Reset App Keywords", systemImage: "trash") + } + .disabled(localKeywords.isEmpty) + } + + if localKeywords.isEmpty { + ContentUnavailableView( + "No Local Keywords", + systemImage: "list.bullet.rectangle", + description: Text("Use Add Keywords to create app-specific tracked keywords.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(localKeywords) { track in + KeywordListRow( + title: track.term, + storefront: storeTitle(for: track.storefront), + platform: track.platform.displayName, + notes: track.notes, + editAction: { + editingEntry = .local(track) + }, + deleteAction: { + deleteLocalKeyword(track) + } + ) + } + .listStyle(.plain) + } + } + } + + private var globalKeywordList: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("\(globalKeywordTemplates.count.formatted()) reusable global keywords") + .foregroundStyle(.secondary) + Spacer() + Button { + editingEntry = .newGlobal(defaultPlatform: trackedApp.defaultPlatform) + } label: { + Label("Add Global Keyword", systemImage: "plus") + } + Button(role: .destructive) { + resetConfirmation = .global(count: globalKeywordTemplates.count) + } label: { + Label("Reset Global Keywords", systemImage: "trash") + } + .disabled(globalKeywordTemplates.isEmpty) + } + + if globalKeywordTemplates.isEmpty { + ContentUnavailableView( + "No Global Keywords", + systemImage: "globe", + description: Text("Add reusable keywords here or save pasted keywords from Add Keywords.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(globalKeywordTemplates) { template in + KeywordListRow( + title: template.term, + storefront: storeTitle(for: template.storefront), + platform: template.platform.displayName, + notes: nil, + editAction: { + editingEntry = .global(template) + }, + deleteAction: { + deleteGlobalKeyword(template) + } + ) + } + .listStyle(.plain) + } + } + } + + private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { + GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + .sorted { lhs, rhs in + if lhs.storefront != rhs.storefront { + return lhs.storefront < rhs.storefront + } + if lhs.platformRaw != rhs.platformRaw { + return lhs.platformRaw < rhs.platformRaw + } + return lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending + } + } + + private func save(entry: KeywordListEditingEntry, draft: KeywordListDraft) { + errorMessage = nil + let normalizedDraft = draft.normalized + + guard !normalizedDraft.term.isEmpty else { + errorMessage = "Enter a keyword." + return + } + guard !normalizedDraft.storefront.isEmpty else { + errorMessage = "Choose a country." + return + } + + do { + switch entry.source { + case .local(let track): + try saveLocalKeyword(track, draft: normalizedDraft) + case .global(let existingID): + try saveGlobalKeywords(existingID: existingID, draft: normalizedDraft) + } + editingEntry = nil + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private func saveLocalKeyword(_ track: TrackedAppKeyword, draft: KeywordListDraft) throws { + let duplicateKey = localDuplicateKey( + term: draft.term, + storefront: draft.storefront, + platform: draft.platform + ) + let hasDuplicate = localKeywords.contains { otherTrack in + otherTrack.persistentModelID != track.persistentModelID + && localDuplicateKey( + term: otherTrack.term, + storefront: otherTrack.storefront, + platform: otherTrack.platform + ) == duplicateKey + } + guard !hasDuplicate else { + throw OpenASOError.providerUnavailable( + "That keyword, country, and device already exist for this app.") + } + + let didChangeTrackingTarget = localDuplicateKey( + term: track.term, + storefront: track.storefront, + platform: track.platform + ) != duplicateKey + + let query = try KeywordQuery.fetchOrInsert( + term: draft.term, + storefront: draft.storefront, + platform: draft.platform, + in: modelContext + ) + + if didChangeTrackingTarget { + deleteSnapshots(for: track) + track.rankingAppCount = nil + track.lastRefreshAt = nil + track.statusMessage = "Edited. Refresh to collect rankings for the new keyword." + } + + track.term = draft.term + track.storefront = draft.storefront + track.platform = draft.platform + track.identityKey = TrackedAppKeyword.makeIdentityKey( + appStoreID: trackedApp.appStoreID, + term: draft.term, + storefront: draft.storefront, + platform: draft.platform + ) + track.query = query + track.notes = draft.notes + + try modelContext.save() + } + + private func saveGlobalKeyword(existingID: String?, draft: KeywordListDraft) throws { + let replacement = GlobalTrackedKeywordTemplate( + term: draft.term, + storefront: draft.storefront, + platform: draft.platform + ) + var templates = globalKeywordTemplates.filter { $0.id != existingID } + + guard !templates.contains(where: { $0.id == replacement.id }) else { + throw OpenASOError.providerUnavailable( + "That global keyword, country, and device already exist.") + } + + templates.append(replacement) + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) + } + + private func saveGlobalKeywords(existingID: String?, draft: KeywordListDraft) throws { + let keywords = parsedKeywords(from: draft.term) + guard !keywords.isEmpty else { + throw OpenASOError.providerUnavailable("Enter at least one keyword.") + } + + if existingID != nil || keywords.count == 1 { + try saveGlobalKeyword( + existingID: existingID, + draft: KeywordListDraft( + term: keywords[0], + storefront: draft.storefront, + platform: draft.platform, + notes: draft.notes + ) + ) + return + } + + var templates = globalKeywordTemplates + var existingIDs = Set(templates.map(\.id)) + var insertedCount = 0 + + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate( + term: keyword, + storefront: draft.storefront, + platform: draft.platform + ) + guard existingIDs.insert(template.id).inserted else { continue } + templates.append(template) + insertedCount += 1 + } + + guard insertedCount > 0 else { + throw OpenASOError.providerUnavailable( + "Those global keywords already exist for this country and device.") + } + + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) + } + + private func deleteLocalKeyword(_ track: TrackedAppKeyword) { + modelContext.delete(track) + do { + try modelContext.save() + services.analyticsService.capture(.keywordDeleted(deleteCount: 1)) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private func deleteGlobalKeyword(_ template: GlobalTrackedKeywordTemplate) { + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList( + globalKeywordTemplates.filter { $0.id != template.id } + ) + } + + private func reset(_ confirmation: KeywordListResetConfirmation) { + switch confirmation { + case .local: + resetLocalKeywords() + case .global: + resetGlobalKeywords() + } + } + + private func resetLocalKeywords() { + let deleteCount = localKeywords.count + for track in localKeywords { + modelContext.delete(track) + } + + do { + try modelContext.save() + services.analyticsService.capture(.keywordDeleted(deleteCount: deleteCount)) + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private func resetGlobalKeywords() { + globalKeywordTemplatesJSON = "[]" + } + + private func deleteSnapshots(for track: TrackedAppKeyword) { + for snapshot in track.snapshots { + for result in snapshot.topResults { + modelContext.delete(result) + } + snapshot.topResults.removeAll() + modelContext.delete(snapshot) + } + track.snapshots.removeAll() + } + + private func localDuplicateKey(term: String, storefront: String, platform: AppPlatform) -> String { + [ + term.normalizedKeywordKey, + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform.rawValue, + ].joined(separator: "::") + } + + private func parsedKeywords(from text: String) -> [String] { + let separators = CharacterSet(charactersIn: ",\n") + let parts = text + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var seen = Set() + return parts.filter { keyword in + seen.insert(keyword.normalizedKeywordKey).inserted + } + } + + private func storeTitle(for code: String) -> String { + let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let storefront = storefronts.first(where: { $0.code.lowercased() == normalizedCode }) else { + return code.uppercased() + } + return storefront.title + } + + private func editorStorefronts(including code: String) -> [KeywordListStorefrontOption] { + var options = storefronts.map { + KeywordListStorefrontOption(code: $0.code.lowercased(), title: $0.title) + } + let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !normalizedCode.isEmpty, !options.contains(where: { $0.code == normalizedCode }) { + options.append(KeywordListStorefrontOption(code: normalizedCode, title: normalizedCode.uppercased())) + } + return options.sorted { lhs, rhs in + lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + } +} + +private enum KeywordListScope: CaseIterable, Identifiable { + case local + case global + + var id: String { title } + + var title: String { + switch self { + case .local: + return "This App" + case .global: + return "Global" + } + } +} + +private enum KeywordListResetConfirmation: Identifiable { + case local(appName: String, count: Int) + case global(count: Int) + + var id: String { + switch self { + case .local: + return "local" + case .global: + return "global" + } + } + + var title: String { + switch self { + case .local: + return "Reset App Keywords?" + case .global: + return "Reset Global Keywords?" + } + } + + var message: String { + switch self { + case .local(let appName, let count): + return + "Delete all \(count.formatted()) tracked keywords for \(appName). This removes the app's keyword tracks and ranking history. The app itself stays tracked." + case .global(let count): + return + "Delete all \(count.formatted()) reusable global keywords. App-specific tracked keywords will not be deleted." + } + } + + var buttonTitle: String { + switch self { + case .local: + return "Reset App Keywords" + case .global: + return "Reset Global Keywords" + } + } +} + +private struct KeywordListRow: View { + let title: String + let storefront: String + let platform: String + let notes: String? + let editAction: () -> Void + let deleteAction: () -> Void + + var body: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.headline) + HStack(spacing: 8) { + Text(storefront) + Text(platform) + if let notes, !notes.isEmpty { + Text(notes) + .lineLimit(1) + } + } + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Edit", action: editAction) + Button(role: .destructive, action: deleteAction) { + Label("Delete", systemImage: "trash") + .labelStyle(.iconOnly) + } + .help("Delete Keyword") + } + .padding(.vertical, 6) + } +} + +private struct KeywordListEntryEditor: View { + @Environment(\.dismiss) private var dismiss + + let title: String + let allowsMultipleKeywords: Bool + let storefronts: [KeywordListStorefrontOption] + let save: (KeywordListDraft) -> Void + + @State private var draft: KeywordListDraft + + init( + title: String, + draft: KeywordListDraft, + allowsMultipleKeywords: Bool, + storefronts: [KeywordListStorefrontOption], + save: @escaping (KeywordListDraft) -> Void + ) { + self.title = title + self.allowsMultipleKeywords = allowsMultipleKeywords + self.storefronts = storefronts + self.save = save + _draft = State(initialValue: draft) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.title3) + .bold() + + if allowsMultipleKeywords { + Text("Paste one keyword per line or separate them with commas.") + .foregroundStyle(.secondary) + TextEditor(text: $draft.term) + .font(.body.monospaced()) + .frame(minHeight: 150) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2)) + } + } else { + TextField("Keyword", text: $draft.term) + .textFieldStyle(.roundedBorder) + } + + Picker("Country", selection: $draft.storefront) { + ForEach(storefronts) { storefront in + Text(storefront.title) + .tag(storefront.code) + } + } + + Picker("Device", selection: $draft.platform) { + ForEach(AppPlatform.allCases) { platform in + Label(platform.displayName, systemImage: platform.keywordSheetSystemImage) + .tag(platform) + } + } + .pickerStyle(.segmented) + + TextField("Notes", text: $draft.notes, axis: .vertical) + .textFieldStyle(.roundedBorder) + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + Button("Save") { + save(draft) + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 500) + } +} + +private struct KeywordListStorefrontOption: Identifiable, Hashable { + let code: String + let title: String + + var id: String { code } +} + +private struct KeywordListDraft: Hashable { + var term: String + var storefront: String + var platform: AppPlatform + var notes: String + + var normalized: KeywordListDraft { + KeywordListDraft( + term: term.trimmingCharacters(in: .whitespacesAndNewlines), + storefront: storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform: platform, + notes: notes.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } +} + +private struct KeywordListEditingEntry: Identifiable { + let id = UUID() + let source: KeywordListEditingSource + let title: String + let draft: KeywordListDraft + + var allowsMultipleKeywords: Bool { + if case .global(existingID: nil) = source { + return true + } + return false + } + + static func local(_ track: TrackedAppKeyword) -> KeywordListEditingEntry { + KeywordListEditingEntry( + source: .local(track), + title: "Edit Local Keyword", + draft: KeywordListDraft( + term: track.term, + storefront: track.storefront, + platform: track.platform, + notes: track.notes + ) + ) + } + + static func global(_ template: GlobalTrackedKeywordTemplate) -> KeywordListEditingEntry { + KeywordListEditingEntry( + source: .global(existingID: template.id), + title: "Edit Global Keyword", + draft: KeywordListDraft( + term: template.term, + storefront: template.storefront, + platform: template.platform, + notes: "" + ) + ) + } + + static func newGlobal(defaultPlatform: AppPlatform) -> KeywordListEditingEntry { + KeywordListEditingEntry( + source: .global(existingID: nil), + title: "Add Global Keyword", + draft: KeywordListDraft( + term: "", + storefront: "us", + platform: defaultPlatform, + notes: "" + ) + ) + } +} + +private enum KeywordListEditingSource { + case local(TrackedAppKeyword) + case global(existingID: String?) +} + +extension String { + fileprivate var normalizedKeywordKey: String { + trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } } -private extension AppPlatform { - var keywordSheetSystemImage: String { +extension AppPlatform { + fileprivate var keywordSheetSystemImage: String { switch self { case .iphone: return "iphone" diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift index 8e1ae92..a552bec 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift @@ -610,7 +610,7 @@ private struct AppStorefrontMetadataDisplayValue { } } -private struct AppStoreScreenshotDisplayValue: Identifiable, Hashable { +private struct AppStoreScreenshotDisplayValue: Identifiable, Hashable, Sendable { let id: String let platformRaw: String let displayTypeRaw: String @@ -649,7 +649,7 @@ private struct AppStoreScreenshotDisplayValue: Identifiable, Hashable { } } -private struct ScreenshotPlatformGroup: Identifiable, Hashable { +private struct ScreenshotPlatformGroup: Identifiable, Hashable, Sendable { let platformRaw: String let screenshots: [AppStoreScreenshotDisplayValue] @@ -1451,7 +1451,7 @@ private struct KeywordRankingListHeader: View { } } -private struct KeywordRankingScreenshotExportApp { +private struct KeywordRankingScreenshotExportApp: Sendable { let rank: Int let appStoreID: Int64 let name: String @@ -1465,13 +1465,13 @@ private struct KeywordRankingScreenshotExportApp { } } -private struct KeywordRankingScreenshotExportSummary { +private struct KeywordRankingScreenshotExportSummary: Sendable { let downloadedCount: Int let failureCount: Int let skippedAppCount: Int } -private final class KeywordRankingScreenshotExportService { +private final class KeywordRankingScreenshotExportService: Sendable { private let downloader: ScreenshotDownloadService init(downloader: ScreenshotDownloadService) { @@ -1644,14 +1644,14 @@ private final class KeywordRankingScreenshotExportService { return formatter } - private struct PlannedApp { + private struct PlannedApp: Sendable { let rank: Int let appStoreID: Int64 let name: String let screenshots: [PlannedScreenshot] } - private struct PlannedScreenshot { + private struct PlannedScreenshot: Sendable { let job: ScreenshotDownloadJob let platformRaw: String let displayTypeRaw: String diff --git a/OpenASO/Features/AppDetail/Pricing/AppPricingView.swift b/OpenASO/Features/AppDetail/Pricing/AppPricingView.swift new file mode 100644 index 0000000..4823156 --- /dev/null +++ b/OpenASO/Features/AppDetail/Pricing/AppPricingView.swift @@ -0,0 +1,446 @@ +import SwiftData +import SwiftUI + +struct AppPricingView: View { + @Environment(AppServices.self) private var services + + @Query(sort: [SortDescriptor(\Storefront.name, order: .forward)]) + private var storefronts: [Storefront] + + let appStoreID: Int64 + let selectedStorefrontFilter: StorefrontFilter + let searchText: String + let refreshToken: Int + + @State private var comparison: OpenASOMCPAppPricingComparison? + @State private var isLoading = false + @State private var pricingFetchProgress: PricingFetchProgress? + @State private var errorMessage: String? + + private var filteredPlans: [OpenASOMCPAppStorefrontPlan] { + let normalizedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return (comparison?.plans ?? []) + .filter { plan in + plan.error == nil && !plan.name.isEmpty + } + .filter { plan in + guard !normalizedSearch.isEmpty else { return true } + return plan.name.lowercased().contains(normalizedSearch) + || plan.storefront.lowercased().contains(normalizedSearch) + || storeTitle(for: plan.storefront).lowercased().contains(normalizedSearch) + || plan.displayPrice.lowercased().contains(normalizedSearch) + } + .sorted { lhs, rhs in + if lhs.name == rhs.name { + return lhs.storefront < rhs.storefront + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private var localizedPricingByPlanKey: [String: OpenASOMCPLocalizedPricingComparison] { + Dictionary(grouping: comparison?.localizedPricing ?? []) { + comparisonKey( + appStoreID: $0.appStoreID, + planName: $0.planName, + storefront: $0.storefront, + displayPrice: $0.displayPrice + ) + }.compactMapValues { comparisons in + comparisons.first { $0.percentDifferenceFromUS != nil } ?? comparisons.first + } + } + + private var failedPricingStorefronts: [String] { + guard let comparison else { return [] } + var storefronts = Set() + for price in comparison.prices where price.appStoreID == String(appStoreID) && price.error != nil { + storefronts.insert(normalizedStorefront(price.storefront)) + } + for plan in comparison.plans where plan.appStoreID == String(appStoreID) && plan.error != nil { + storefronts.insert(normalizedStorefront(plan.storefront)) + } + return storefronts + .filter { !$0.isEmpty } + .sorted { storeTitle(for: $0).localizedStandardCompare(storeTitle(for: $1)) == .orderedAscending } + } + + private var isRefreshInProgress: Bool { + if services.refreshProgressStore.pendingAppRefreshCount > 0 { + return true + } + + guard let refresh = services.refreshProgressStore.activeRefresh else { + return false + } + + switch refresh.phase { + case .completed, .failed: + return false + case .preparing, .refreshingKeywords, .refreshingMetrics, .refreshingRatings, + .refreshingReviews, .refreshingPricing, .finishing: + return true + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Pricing") + .font(.title2.bold()) + Text("Public App Store prices and visible in-app purchase rows by country.") + .foregroundStyle(.secondary) + } + Spacer() + if !failedPricingStorefronts.isEmpty { + Button { + Task { await retryFailedPricing() } + } label: { + Label("Retry Failed", systemImage: "arrow.clockwise.circle") + } + .disabled(isLoading || isRefreshInProgress) + } + Button { + Task { await loadPricing(forceRefresh: true) } + } label: { + Label("Refresh Pricing", systemImage: "arrow.clockwise") + } + .disabled(isLoading || isRefreshInProgress) + } + + if isLoading { + VStack(spacing: 10) { + ProgressView( + value: Double(pricingFetchProgress?.completed ?? 0), + total: Double(max(pricingFetchProgress?.total ?? 1, 1)) + ) + .frame(width: 220) + Text(pricingProgressTitle) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView( + "Pricing Unavailable", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredPlans.isEmpty { + ContentUnavailableView( + comparison == nil ? "Pricing Not Loaded" : "No Plans Found", + systemImage: "tag", + description: Text(comparison == nil + ? "Use Refresh Pricing or Refresh All Apps to fetch pricing for this app." + : "The public App Store page did not expose visible in-app purchase or subscription rows for this selection.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + pricingTable + } + } + .padding(24) + .task(id: refreshSignature) { + await loadPricing(forceRefresh: false) + } + } + + private var pricingTable: some View { + Table(filteredPlans) { + TableColumn("Plan") { plan in + VStack(alignment: .leading, spacing: 3) { + Text(plan.name) + .fontWeight(.medium) + Text(plan.billingCadence?.capitalized ?? "Cadence unknown") + .font(.caption) + .foregroundStyle(plan.billingCadence == nil ? .tertiary : .secondary) + } + } + + TableColumn("Country") { plan in + Text("\(storeFlag(for: plan.storefront)) \(storeTitle(for: plan.storefront))") + } + + TableColumn("Local Price") { plan in + VStack(alignment: .leading, spacing: 3) { + Text(plan.displayPrice) + .fontWeight(.semibold) + Text(plan.currency ?? "Currency unknown") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + TableColumn("USD") { plan in + Text(formattedUSD(plan.priceUSD)) + .foregroundStyle(plan.priceUSD == nil ? .tertiary : .primary) + } + + TableColumn("Vs US") { plan in + let localizedPricing = localizedPricingByPlanKey[ + comparisonKey( + appStoreID: plan.appStoreID, + planName: plan.name, + storefront: plan.storefront, + displayPrice: plan.displayPrice + ) + ] + PricingDeltaBadge(comparison: localizedPricing) + } + } + } + + private var refreshSignature: String { + [ + String(appStoreID), + selectedStorefrontFilter.id, + String(refreshToken), + ].joined(separator: "::") + } + + @MainActor + private func loadPricing(forceRefresh: Bool) async { + guard !isLoading else { return } + if !comparisonMatchesCurrentApp { + comparison = nil + } + guard let backgroundModelStore = services.backgroundModelStore else { + errorMessage = "The workspace store is not ready." + return + } + + isLoading = true + pricingFetchProgress = nil + errorMessage = nil + defer { + isLoading = false + pricingFetchProgress = nil + } + + do { + if !forceRefresh, + let cached = await services.appPricingCache.comparison( + appStoreID: appStoreID, + requestedStorefronts: requestedStorefronts + ) + { + comparison = cached + return + } + + if !forceRefresh, + let persisted = try await AppPricingPersistence.comparison( + appStoreID: appStoreID, + requestedStorefronts: requestedStorefronts, + using: backgroundModelStore + ) + { + await services.appPricingCache.store(persisted) + comparison = persisted + return + } + + if !forceRefresh { + return + } + + guard !isRefreshInProgress else { return } + + let service = OpenASOMCPService( + backgroundModelStore: backgroundModelStore, + appResolver: services.appResolver, + appCatalogService: services.appCatalogService + ) + let fetched = try await service.compareAppPricing( + appStoreIDs: [appStoreID], + storefronts: requestedStorefronts + ) { completed, total, failureCount in + await MainActor.run { + pricingFetchProgress = PricingFetchProgress( + completed: completed, + total: total, + failureCount: failureCount + ) + } + } + await services.appPricingCache.store(fetched) + try await AppPricingPersistence.store(fetched, using: backgroundModelStore) + comparison = fetched + } catch { + comparison = nil + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + @MainActor + private func retryFailedPricing() async { + let failedStorefronts = failedPricingStorefronts + guard !failedStorefronts.isEmpty else { return } + guard let existingComparison = comparison else { return } + guard !isLoading else { return } + guard let backgroundModelStore = services.backgroundModelStore else { + errorMessage = "The workspace store is not ready." + return + } + guard !isRefreshInProgress else { return } + + isLoading = true + pricingFetchProgress = nil + errorMessage = nil + defer { + isLoading = false + pricingFetchProgress = nil + } + + do { + let service = OpenASOMCPService( + backgroundModelStore: backgroundModelStore, + appResolver: services.appResolver, + appCatalogService: services.appCatalogService + ) + let retryComparison = try await service.compareAppPricing( + appStoreIDs: [appStoreID], + storefronts: failedStorefronts + ) { completed, total, failureCount in + await MainActor.run { + pricingFetchProgress = PricingFetchProgress( + completed: completed, + total: total, + failureCount: failureCount + ) + } + } + let merged = mergedComparison( + existing: existingComparison, + retry: retryComparison, + retriedStorefronts: failedStorefronts + ) + await services.appPricingCache.store(merged) + try await AppPricingPersistence.store(merged, using: backgroundModelStore) + comparison = merged + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private var requestedStorefronts: [String]? { + switch selectedStorefrontFilter { + case .all: + return nil + case .storefront(let code, _): + let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedCode == "us" ? ["us"] : ["us", normalizedCode] + } + } + + private var comparisonMatchesCurrentApp: Bool { + guard let comparison else { return true } + return comparison.appStoreIDs.contains(String(appStoreID)) + } + + private func mergedComparison( + existing: OpenASOMCPAppPricingComparison, + retry: OpenASOMCPAppPricingComparison, + retriedStorefronts: [String] + ) -> OpenASOMCPAppPricingComparison { + let retried = Set(retriedStorefronts.map(normalizedStorefront)) + let prices = existing.prices.filter { !retried.contains(normalizedStorefront($0.storefront)) } + + retry.prices + let plans = existing.plans.filter { !retried.contains(normalizedStorefront($0.storefront)) } + + retry.plans + let storefronts = Array(Set(existing.storefronts.map(normalizedStorefront) + retry.storefronts.map(normalizedStorefront))) + .filter { !$0.isEmpty } + .sorted() + let appStoreIDs = Array(Set(existing.appStoreIDs + retry.appStoreIDs)).sorted() + + return OpenASOMCPAppPricingComparison( + generatedAt: retry.generatedAt, + appStoreIDs: appStoreIDs, + storefronts: storefronts, + prices: prices, + plans: plans, + localizedPricing: OpenASOMCPService.localizedPricingComparisons(from: plans), + notes: retry.notes.isEmpty ? existing.notes : retry.notes + ) + } + + private var pricingProgressTitle: String { + guard let pricingFetchProgress else { + return "Preparing pricing fetch" + } + + let base = "Fetching pricing \(pricingFetchProgress.completed) of \(pricingFetchProgress.total) countries" + guard pricingFetchProgress.failureCount > 0 else { return base } + return "\(base), \(pricingFetchProgress.failureCount) failed" + } + + private func storeTitle(for code: String) -> String { + let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return storefronts.first { $0.code.lowercased() == normalizedCode }?.title + ?? normalizedCode.uppercased() + } + + private func storeFlag(for code: String) -> String { + storefronts.first { $0.code.lowercased() == code.lowercased() }?.flagEmoji ?? "" + } + + private func formattedUSD(_ value: Double?) -> String { + guard let value else { return "-" } + return value.formatted(.currency(code: "USD").precision(.fractionLength(2))) + } + + private func comparisonKey(appStoreID: String, planName: String, storefront: String, displayPrice: String) -> String { + [ + appStoreID, + normalizedPlanName(planName), + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + normalizedPlanName(displayPrice), + ].joined(separator: "::") + } + + private func normalizedPlanName(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func normalizedStorefront(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +private struct PricingDeltaBadge: View { + let comparison: OpenASOMCPLocalizedPricingComparison? + + var body: some View { + guard let comparison else { + return AnyView( + Text("US baseline") + .font(.caption) + .foregroundStyle(.secondary) + ) + } + + guard let percentDifference = comparison.percentDifferenceFromUS else { + return AnyView( + Text("No FX") + .font(.caption) + .foregroundStyle(.secondary) + ) + } + + let label = percentDifference.formatted(.number.precision(.fractionLength(1))) + "%" + let color: Color = percentDifference < -1 ? .green : (percentDifference > 1 ? .orange : .secondary) + let prefix = percentDifference < -1 ? "Lower " : (percentDifference > 1 ? "Higher " : "") + return AnyView( + Text(prefix + label) + .font(.caption.weight(.semibold)) + .foregroundStyle(color) + ) + } +} + +private struct PricingFetchProgress { + let completed: Int + let total: Int + let failureCount: Int +} diff --git a/OpenASO/Models/AppPricingSnapshot.swift b/OpenASO/Models/AppPricingSnapshot.swift new file mode 100644 index 0000000..3250d3f --- /dev/null +++ b/OpenASO/Models/AppPricingSnapshot.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +extension OpenASOSchemaV2 { +@Model +final class AppPricingSnapshot { + #Index( + [\.appStoreID], + [\.updatedAt] + ) + + @Attribute(.unique) var appStoreID: Int64 + var generatedAt: Date + var updatedAt: Date + var storefrontsJSON: Data + var comparisonJSON: Data + + init( + appStoreID: Int64, + generatedAt: Date, + storefrontsJSON: Data, + comparisonJSON: Data, + updatedAt: Date = .now + ) { + self.appStoreID = appStoreID + self.generatedAt = generatedAt + self.updatedAt = updatedAt + self.storefrontsJSON = storefrontsJSON + self.comparisonJSON = comparisonJSON + } +} +} + +typealias AppPricingSnapshot = OpenASOSchemaV2.AppPricingSnapshot diff --git a/OpenASO/Models/KeywordMetricsSource.swift b/OpenASO/Models/KeywordMetricsSource.swift index dc7213f..4240314 100644 --- a/OpenASO/Models/KeywordMetricsSource.swift +++ b/OpenASO/Models/KeywordMetricsSource.swift @@ -2,6 +2,7 @@ import Foundation enum KeywordMetricsSource: String, Codable, CaseIterable, Identifiable, Sendable { case appleAdsPopularity + case rankingDifficulty var id: String { rawValue } @@ -9,6 +10,8 @@ enum KeywordMetricsSource: String, Codable, CaseIterable, Identifiable, Sendable switch self { case .appleAdsPopularity: return "Apple Ads" + case .rankingDifficulty: + return "Ranking difficulty" } } } diff --git a/OpenASO/Models/OpenASOSchemaV1.swift b/OpenASO/Models/OpenASOSchemaV1.swift index 51bc28e..4504e85 100644 --- a/OpenASO/Models/OpenASOSchemaV1.swift +++ b/OpenASO/Models/OpenASOSchemaV1.swift @@ -28,14 +28,49 @@ enum OpenASOSchemaV1: VersionedSchema { } } +enum OpenASOSchemaV2: VersionedSchema { + static var versionIdentifier: Schema.Version { + Schema.Version(1, 1, 0) + } + + static var models: [any PersistentModel.Type] { + [ + AppFolder.self, + AppKeywordStats.self, + AppPricingSnapshot.self, + LatestAppRating.self, + AppDailyRating.self, + AppStorefrontReview.self, + StoreApp.self, + AppStorefrontMetadata.self, + AppStoreScreenshot.self, + KeywordQuery.self, + KeywordDailyMetric.self, + KeywordRankingCrawl.self, + KeywordAppRanking.self, + TrackedApp.self, + TrackedAppKeyword.self, + TrackedKeywordDailyRanking.self, + TrackedKeywordRankedResult.self, + Storefront.self + ] + } +} + enum OpenASOMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ - OpenASOSchemaV1.self + OpenASOSchemaV1.self, + OpenASOSchemaV2.self ] } static var stages: [MigrationStage] { - [] + [ + .lightweight( + fromVersion: OpenASOSchemaV1.self, + toVersion: OpenASOSchemaV2.self + ) + ] } } diff --git a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift index bf3deb7..15e9b49 100644 --- a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift +++ b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift @@ -13,6 +13,7 @@ struct AppDetailRefreshAppSnapshot: Sendable { enum AppDetailRefreshWorkspace: Sendable { case keywords case ratings + case pricing } enum AppDetailRefreshStorefrontSelection: Sendable { @@ -39,6 +40,7 @@ struct AppDetailRefreshRequest: Sendable { let refreshMetrics: Bool let refreshRatings: Bool let refreshReviews: Bool + let pricingStorefronts: [String] let recordsRatingsReviewsRefresh: Bool let popularityContextAppStoreID: Int64? let appleAdsWebSession: AppleAdsWebSession? @@ -54,6 +56,7 @@ struct AppDetailRefreshRequest: Sendable { refreshMetrics: Bool = true, refreshRatings: Bool = true, refreshReviews: Bool = true, + pricingStorefronts: [String] = [], recordsRatingsReviewsRefresh: Bool = true, popularityContextAppStoreID: Int64?, appleAdsWebSession: AppleAdsWebSession?, @@ -68,6 +71,7 @@ struct AppDetailRefreshRequest: Sendable { self.refreshMetrics = refreshMetrics self.refreshRatings = refreshRatings self.refreshReviews = refreshReviews + self.pricingStorefronts = pricingStorefronts self.recordsRatingsReviewsRefresh = recordsRatingsReviewsRefresh self.popularityContextAppStoreID = popularityContextAppStoreID self.appleAdsWebSession = appleAdsWebSession @@ -90,13 +94,42 @@ private struct RankingPersistenceBatchOutcome: Sendable { } } +private struct RankingRefreshWorkItem: Sendable { + let fetchRequest: RankingRefreshRequest + let targetRequests: [RankingRefreshRequest] +} + struct AppDetailRefreshResult: Sendable { let keywordOutcomes: [KeywordBackgroundRefreshOutcome] let ratingOutcomes: [AppStorefrontRatingRefreshOutcome] let reviewOutcomes: [AppStorefrontReviewRefreshOutcome] + let pricingComparison: OpenASOMCPAppPricingComparison? + let pricingError: OpenASOError? let firstError: OpenASOError? } +actor AppPricingCache { + private var comparisonsByAppStoreID: [Int64: OpenASOMCPAppPricingComparison] = [:] + + func comparison(appStoreID: Int64, requestedStorefronts: [String]?) -> OpenASOMCPAppPricingComparison? { + guard let comparison = comparisonsByAppStoreID[appStoreID] else { return nil } + guard let requestedStorefronts else { return comparison } + let cachedStorefronts = Set(comparison.storefronts.map(Self.normalizedStorefront)) + let requested = Set(requestedStorefronts.map(Self.normalizedStorefront)) + return requested.isSubset(of: cachedStorefronts) ? comparison : nil + } + + func store(_ comparison: OpenASOMCPAppPricingComparison) { + for appStoreID in comparison.appStoreIDs.compactMap(Int64.init) { + comparisonsByAppStoreID[appStoreID] = comparison + } + } + + private static func normalizedStorefront(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + private actor AppDetailRefreshQueue { private struct Job: Sendable { let request: AppDetailRefreshRequest @@ -158,6 +191,14 @@ final class AppDetailRefreshService: Sendable { private let appStoreConnectReviewService: AppStoreConnectReviewService private let progressStore: AppRefreshProgressStore? private let ratingsReviewsRefreshRecorder: (@Sendable (Date) async -> Void)? + private let pricingRefresh: ( + @Sendable ( + Int64, + [String], + @escaping @Sendable (Int, Int, Int) async -> Void + ) async throws -> OpenASOMCPAppPricingComparison + )? + private let pricingCache: AppPricingCache? init( backgroundModelStore: BackgroundModelStore, @@ -167,7 +208,15 @@ final class AppDetailRefreshService: Sendable { appStorefrontReviewService: AppStorefrontReviewService, appStoreConnectReviewService: AppStoreConnectReviewService, progressStore: AppRefreshProgressStore? = nil, - ratingsReviewsRefreshRecorder: (@Sendable (Date) async -> Void)? = nil + ratingsReviewsRefreshRecorder: (@Sendable (Date) async -> Void)? = nil, + pricingRefresh: ( + @Sendable ( + Int64, + [String], + @escaping @Sendable (Int, Int, Int) async -> Void + ) async throws -> OpenASOMCPAppPricingComparison + )? = nil, + pricingCache: AppPricingCache? = nil ) { self.backgroundModelStore = backgroundModelStore self.refreshCoordinator = refreshCoordinator @@ -177,6 +226,8 @@ final class AppDetailRefreshService: Sendable { self.appStoreConnectReviewService = appStoreConnectReviewService self.progressStore = progressStore self.ratingsReviewsRefreshRecorder = ratingsReviewsRefreshRecorder + self.pricingRefresh = pricingRefresh + self.pricingCache = pricingCache } func refresh(_ request: AppDetailRefreshRequest) async -> AppDetailRefreshResult { @@ -192,9 +243,13 @@ final class AppDetailRefreshService: Sendable { let keywordOutcomes: [KeywordBackgroundRefreshOutcome] let ratingOutcomes: [AppStorefrontRatingRefreshOutcome] let reviewOutcomes: [AppStorefrontReviewRefreshOutcome] + let pricingComparison: OpenASOMCPAppPricingComparison? + let pricingError: OpenASOError? switch request.workspace { case .keywords: + pricingComparison = nil + pricingError = nil keywordOutcomes = await refreshKeywords(request) if request.refreshRatings || request.refreshReviews { (ratingOutcomes, reviewOutcomes) = await refreshRatingsAndReviews(request) @@ -203,6 +258,8 @@ final class AppDetailRefreshService: Sendable { reviewOutcomes = [] } case .ratings: + pricingComparison = nil + pricingError = nil if request.refreshRatings || request.refreshReviews { (ratingOutcomes, reviewOutcomes) = await refreshRatingsAndReviews(request) } else { @@ -210,9 +267,16 @@ final class AppDetailRefreshService: Sendable { reviewOutcomes = [] } keywordOutcomes = await refreshKeywords(request) + case .pricing: + keywordOutcomes = [] + ratingOutcomes = [] + reviewOutcomes = [] + let pricingResult = await refreshPricing(request) + pricingComparison = pricingResult.comparison + pricingError = pricingResult.error } - let firstError = firstRefreshError( + let firstError = pricingError ?? firstRefreshError( workspace: request.workspace, keywordOutcomes: keywordOutcomes, ratingOutcomes: ratingOutcomes, @@ -225,12 +289,69 @@ final class AppDetailRefreshService: Sendable { keywordOutcomes: keywordOutcomes, ratingOutcomes: ratingOutcomes, reviewOutcomes: reviewOutcomes, + pricingComparison: pricingComparison, + pricingError: pricingError, firstError: firstError ) } + private func refreshPricing(_ request: AppDetailRefreshRequest) async + -> (comparison: OpenASOMCPAppPricingComparison?, error: OpenASOError?) + { + await progressStore?.updatePhase(.refreshingPricing) + guard let pricingRefresh else { + await progressStore?.updateStep(.pricing, status: .failed, completed: 0, total: 0, failureCount: 1) + return (nil, .providerUnavailable("The pricing refresh service is unavailable.")) + } + + let storefronts = request.pricingStorefronts.isEmpty + ? Self.pricingStorefronts(from: request.storefrontSelection) + : request.pricingStorefronts + await progressStore?.updateStep( + .pricing, + status: storefronts.isEmpty ? .skipped : .running, + completed: 0, + total: storefronts.count, + failureCount: 0 + ) + + do { + let comparison = try await pricingRefresh( + request.app.appStoreID, + storefronts + ) { completed, total, failureCount in + await self.progressStore?.updateStep( + .pricing, + status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, + completed: completed, + total: total, + failureCount: failureCount + ) + } + await pricingCache?.store(comparison) + try await AppPricingPersistence.store(comparison, using: backgroundModelStore) + return (comparison, nil) + } catch { + await progressStore?.updateStep( + .pricing, + status: .failed, + completed: 0, + total: storefronts.count, + failureCount: max(1, storefronts.count) + ) + return (nil, OpenASOError.map(error)) + } + } + + private static func pricingStorefronts(from selection: AppDetailRefreshStorefrontSelection) -> [String] { + let selected = selection.codes + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + return Array(Set(selected + ["us"])).sorted() + } + private func refreshKeywords(_ request: AppDetailRefreshRequest) async -> [KeywordBackgroundRefreshOutcome] { - guard request.refreshKeywords, !request.trackIdentityKeys.isEmpty else { + guard request.refreshKeywords || request.refreshMetrics, !request.trackIdentityKeys.isEmpty else { await progressStore?.updateStep(.keywords, status: .skipped, completed: 0, total: 0, failureCount: 0) await progressStore?.updateStep(.metrics, status: .skipped, completed: 0, total: 0, failureCount: 0) return [] @@ -264,12 +385,24 @@ final class AppDetailRefreshService: Sendable { ) } - let keywordOutcomes = await refreshRankings( - rankingRequests, - trigger: request.trigger, - missingFailureCount: missingFailureCount, - totalRequestedCount: request.trackIdentityKeys.count - ) + let keywordOutcomes: [KeywordBackgroundRefreshOutcome] + if request.refreshKeywords { + keywordOutcomes = await refreshRankings( + rankingRequests, + trigger: request.trigger, + missingFailureCount: missingFailureCount, + totalRequestedCount: request.trackIdentityKeys.count + ) + } else { + await progressStore?.updateStep( + .keywords, + status: .skipped, + completed: 0, + total: 0, + failureCount: 0 + ) + keywordOutcomes = [] + } if request.refreshMetrics { await progressStore?.updatePhase(.refreshingMetrics) @@ -332,6 +465,7 @@ final class AppDetailRefreshService: Sendable { var outcomes: [KeywordBackgroundRefreshOutcome] = [] var statsRebuildRequests = Set() var pendingPageResults: [RankingRefreshPageResult] = [] + let workItems = rankingRefreshWorkItems(from: rankingRequests) var completedCount = 0 var failureCount = 0 @@ -349,52 +483,54 @@ final class AppDetailRefreshService: Sendable { } } - await withTaskGroup(of: (RankingRefreshRequest, Result).self) { group in - var nextRequestIndex = 0 + await withTaskGroup(of: (RankingRefreshWorkItem, Result).self) { group in + var nextWorkItemIndex = 0 var activeFetchCount = 0 func enqueueNextFetchIfPossible() { guard activeFetchCount < Self.rankingFetchConcurrency, - nextRequestIndex < rankingRequests.count else { + nextWorkItemIndex < workItems.count else { return } - let rankingRequest = rankingRequests[nextRequestIndex] - nextRequestIndex += 1 + let workItem = workItems[nextWorkItemIndex] + nextWorkItemIndex += 1 activeFetchCount += 1 group.addTask { - let result = await rankingPageFetcher(rankingRequest) - return (rankingRequest, result) + let result = await rankingPageFetcher(workItem.fetchRequest) + return (workItem, result) } } - for _ in 0..= Self.rankingPersistenceBatchSize { await flushPendingPageResults() } case .failure(let error): - try? await backgroundModelStore.write { modelContext in - _ = try refreshCoordinator.recordRefreshFailure( - identityKey: rankingRequest.identityKey, - error: error, - in: modelContext, - saveChanges: false - ) + for rankingRequest in workItem.targetRequests { + try? await backgroundModelStore.write { modelContext in + _ = try refreshCoordinator.recordRefreshFailure( + identityKey: rankingRequest.identityKey, + error: error, + in: modelContext, + saveChanges: false + ) + } + outcomes.append(KeywordBackgroundRefreshOutcome(trackIdentityKey: rankingRequest.identityKey, error: error)) } - outcomes.append(KeywordBackgroundRefreshOutcome(trackIdentityKey: rankingRequest.identityKey, error: error)) - failureCount += 1 + failureCount += workItem.targetRequests.count } - completedCount += 1 + completedCount += workItem.targetRequests.count await progressStore?.updateStep( .keywords, status: completedCount >= rankingRequests.count @@ -433,6 +569,40 @@ final class AppDetailRefreshService: Sendable { return outcomes } + private func rankingRefreshWorkItems(from requests: [RankingRefreshRequest]) -> [RankingRefreshWorkItem] { + Dictionary(grouping: requests, by: \.queryKey) + .values + .map { groupedRequests in + let orderedRequests = groupedRequests.sorted { lhs, rhs in + lhs.identityKey.localizedStandardCompare(rhs.identityKey) == .orderedAscending + } + return RankingRefreshWorkItem( + fetchRequest: orderedRequests[0], + targetRequests: orderedRequests + ) + } + .sorted { lhs, rhs in + lhs.fetchRequest.queryKey.localizedStandardCompare(rhs.fetchRequest.queryKey) == .orderedAscending + } + } + + private func pageResults( + from pageResult: RankingRefreshPageResult, + for targetRequests: [RankingRefreshRequest] + ) -> [RankingRefreshPageResult] { + targetRequests.map { request in + RankingRefreshPageResult( + request: request, + page: pageResult.page, + searchedAt: pageResult.searchedAt, + observedHour: pageResult.observedHour, + submissionCount: pageResult.submissionCount, + winningCount: pageResult.winningCount, + confidence: targetRequests.count > 1 ? "shared_query_fetch" : pageResult.confidence + ) + } + } + private func persistRankingPageBatch( _ pageResults: [RankingRefreshPageResult] ) async -> RankingPersistenceBatchOutcome { @@ -781,6 +951,8 @@ final class AppDetailRefreshService: Sendable { return keywordError ?? ratingError ?? reviewError case .ratings: return ratingError ?? reviewError ?? keywordError + case .pricing: + return nil } } } diff --git a/OpenASO/Services/AppDetail/AppPricingPersistence.swift b/OpenASO/Services/AppDetail/AppPricingPersistence.swift new file mode 100644 index 0000000..9c974b4 --- /dev/null +++ b/OpenASO/Services/AppDetail/AppPricingPersistence.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftData + +enum AppPricingPersistence { + static func comparison( + appStoreID: Int64, + requestedStorefronts: [String]?, + using backgroundModelStore: BackgroundModelStore + ) async throws -> OpenASOMCPAppPricingComparison? { + let requested = requestedStorefronts.map { storefronts in + Set(storefronts.map(normalizedStorefront).filter { !$0.isEmpty }) + } + + return try await backgroundModelStore.read { modelContext in + var descriptor = FetchDescriptor( + predicate: #Predicate { snapshot in + snapshot.appStoreID == appStoreID + } + ) + descriptor.fetchLimit = 1 + guard let snapshot = try modelContext.fetch(descriptor).first else { + return nil + } + + let comparison = try JSONDecoder().decode( + OpenASOMCPAppPricingComparison.self, + from: snapshot.comparisonJSON + ) + guard let requested else { return comparison } + + let stored = Set(comparison.storefronts.map(normalizedStorefront).filter { !$0.isEmpty }) + return requested.isSubset(of: stored) ? comparison : nil + } + } + + static func store( + _ comparison: OpenASOMCPAppPricingComparison, + using backgroundModelStore: BackgroundModelStore + ) async throws { + let appStoreIDs = comparison.appStoreIDs.compactMap(Int64.init) + guard !appStoreIDs.isEmpty else { return } + + let normalizedStorefronts = comparison.storefronts + .map(normalizedStorefront) + .filter { !$0.isEmpty } + .sorted() + let storefrontsJSON = try JSONEncoder().encode(normalizedStorefronts) + let comparisonJSON = try JSONEncoder().encode(comparison) + let generatedAt = comparison.generatedAt + let updatedAt = Date() + + try await backgroundModelStore.write { modelContext in + for appStoreID in appStoreIDs { + var descriptor = FetchDescriptor( + predicate: #Predicate { snapshot in + snapshot.appStoreID == appStoreID + } + ) + descriptor.fetchLimit = 1 + + if let snapshot = try modelContext.fetch(descriptor).first { + snapshot.generatedAt = generatedAt + snapshot.updatedAt = updatedAt + snapshot.storefrontsJSON = storefrontsJSON + snapshot.comparisonJSON = comparisonJSON + } else { + modelContext.insert(AppPricingSnapshot( + appStoreID: appStoreID, + generatedAt: generatedAt, + storefrontsJSON: storefrontsJSON, + comparisonJSON: comparisonJSON, + updatedAt: updatedAt + )) + } + } + } + } + + private static func normalizedStorefront(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift index 6739f56..8be4578 100644 --- a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift +++ b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift @@ -7,6 +7,7 @@ enum AppRefreshPhase: Sendable { case refreshingMetrics case refreshingRatings case refreshingReviews + case refreshingPricing case finishing case completed case failed @@ -23,6 +24,8 @@ enum AppRefreshPhase: Sendable { return "Refreshing ratings" case .refreshingReviews: return "Refreshing reviews" + case .refreshingPricing: + return "Refreshing pricing" case .finishing: return "Finishing refresh" case .completed: @@ -38,6 +41,7 @@ enum AppRefreshStep: Sendable { case metrics case ratings case reviews + case pricing } enum AppRefreshStepStatus: Sendable { @@ -122,6 +126,7 @@ struct AppRefreshProgress: Identifiable, Sendable { var metricsProgress: AppRefreshStepProgress var ratingsProgress: AppRefreshStepProgress var reviewsProgress: AppRefreshStepProgress + var pricingProgress: AppRefreshStepProgress var completedAt: Date? var errorMessage: String? @@ -131,10 +136,12 @@ struct AppRefreshProgress: Identifiable, Sendable { var completedUnits: Int { keywordProgress.completed + metricsProgress.completed + ratingsProgress.completed + reviewsProgress.completed + + pricingProgress.completed } var totalUnits: Int { keywordProgress.total + metricsProgress.total + ratingsProgress.total + reviewsProgress.total + + pricingProgress.total } } @@ -187,6 +194,7 @@ final class AppRefreshProgressStore: Sendable { metricsProgress: .pending(total: request.refreshMetrics ? request.trackIdentityKeys.count : 0), ratingsProgress: .pending(total: request.refreshRatings ? storefrontCount : 0), reviewsProgress: .pending(total: request.refreshReviews ? (usesAppStoreConnectReviews ? 1 : storefrontCount) : 0), + pricingProgress: .pending(total: request.workspace == .pricing ? request.pricingStorefronts.count : 0), completedAt: nil, errorMessage: nil ) @@ -207,6 +215,7 @@ final class AppRefreshProgressStore: Sendable { metricsProgress: .pending(total: total), ratingsProgress: .pending(total: 0), reviewsProgress: .pending(total: 0), + pricingProgress: .pending(total: 0), completedAt: nil, errorMessage: nil ) @@ -241,6 +250,8 @@ final class AppRefreshProgressStore: Sendable { refresh.ratingsProgress = progress case .reviews: refresh.reviewsProgress = progress + case .pricing: + refresh.pricingProgress = progress } activeRefresh = refresh } @@ -254,6 +265,7 @@ final class AppRefreshProgressStore: Sendable { refresh.metricsProgress = finalized(refresh.metricsProgress) refresh.ratingsProgress = finalized(refresh.ratingsProgress) refresh.reviewsProgress = finalized(refresh.reviewsProgress) + refresh.pricingProgress = finalized(refresh.pricingProgress) activeRefresh = refresh scheduleClear(refreshID: refresh.id) } diff --git a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift index 6bff1f9..2086e18 100644 --- a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift +++ b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift @@ -1015,7 +1015,7 @@ private struct ReportingCampaignApp: Decodable { let adamId: String } -struct AppleAdsCMPopularityClient { +struct AppleAdsCMPopularityClient: Sendable { static let maxTermsPerRequest = 100 private let httpClient: HTTPClient diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index 6c76afd..690e5b9 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -7,6 +7,7 @@ final class KeywordMetricsService: Sendable { @MainActor private let popularityClient: AppleAdsPopularityClient @MainActor private let settingsStore: AppSettingsStore private let metricsTTL: TimeInterval = 60 * 60 * 24 * 7 + private static let popularityStorefrontConcurrency = 2 @MainActor init( @@ -194,50 +195,106 @@ final class KeywordMetricsService: Sendable { } let cmPopularityClient = AppleAdsCMPopularityClient(httpClient: httpClient) - for (_, storefrontTracks) in Dictionary(grouping: tracksNeedingPopularity, by: \.storefront) { - let storefrontCode = storefrontTracks.first?.storefront ?? "US" - do { - let popularities = try await cmPopularityClient.keywordPopularities( - for: storefrontTracks.map(\.term), - storefrontCode: storefrontCode, - adamId: popularityContextAppStoreID, - session: webSession + let storefrontGroups = Dictionary(grouping: tracksNeedingPopularity, by: \.storefront) + .map { storefront, candidates in + KeywordMetricsStorefrontBatch( + storefront: storefront, + candidates: candidates ) - for candidate in storefrontTracks { - let result: AppleAdsPopularityResult - if let popularity = popularities[AppleAdsCMPopularityClient.normalizedKeywordKey(candidate.term)] { - result = .success(popularity) - } else { - result = .notFound - } - let outcome = try await persistMetricsPayload( - Self.makeAppleAdsMetrics(popularityResult: result), - for: candidate, - using: modelStore - ) - outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } - completedCount += 1 - await progress?(completedCount, totalCount, failureCount) - } - } catch { - for candidate in storefrontTracks { - let outcome = try await persistMetricsPayload( - Self.makeAppleAdsMetrics(popularityResult: .failure(OpenASOError.map(error).localizedDescription)), - for: candidate, - using: modelStore - ) - outcomes.append(outcome) - if outcome.errorMessage != nil { failureCount += 1 } - completedCount += 1 - await progress?(completedCount, totalCount, failureCount) + } + .sorted { $0.storefront < $1.storefront } + + for await batchResult in Self.popularityBatchResults( + storefrontGroups, + cmPopularityClient: cmPopularityClient, + popularityContextAppStoreID: popularityContextAppStoreID, + webSession: webSession + ) { + for candidate in batchResult.candidates { + let result: AppleAdsPopularityResult + if let errorMessage = batchResult.errorMessage { + result = .failure(errorMessage) + } else if let popularity = batchResult.popularities[AppleAdsCMPopularityClient.normalizedKeywordKey(candidate.term)] { + result = .success(popularity) + } else { + result = .notFound } + + let outcome = try await persistMetricsPayload( + Self.makeAppleAdsMetrics(popularityResult: result), + for: candidate, + using: modelStore + ) + outcomes.append(outcome) + if outcome.errorMessage != nil { failureCount += 1 } + completedCount += 1 + await progress?(completedCount, totalCount, failureCount) } } return outcomes } + private static func popularityBatchResults( + _ batches: [KeywordMetricsStorefrontBatch], + cmPopularityClient: AppleAdsCMPopularityClient, + popularityContextAppStoreID: Int64, + webSession: AppleAdsWebSession + ) -> AsyncStream { + AsyncStream { continuation in + Task { + await withTaskGroup(of: KeywordMetricsStorefrontBatchResult.self) { group in + var nextBatchIndex = 0 + var activeFetchCount = 0 + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < popularityStorefrontConcurrency, + nextBatchIndex < batches.count + else { + return + } + + let batch = batches[nextBatchIndex] + nextBatchIndex += 1 + activeFetchCount += 1 + group.addTask { + do { + let popularities = try await cmPopularityClient.keywordPopularities( + for: batch.candidates.map(\.term), + storefrontCode: batch.storefront, + adamId: popularityContextAppStoreID, + session: webSession + ) + return KeywordMetricsStorefrontBatchResult( + candidates: batch.candidates, + popularities: popularities, + errorMessage: nil + ) + } catch { + return KeywordMetricsStorefrontBatchResult( + candidates: batch.candidates, + popularities: [:], + errorMessage: OpenASOError.map(error).localizedDescription + ) + } + } + } + + for _ in 0.. ReadResource.Result in @@ -76,6 +77,9 @@ struct OpenASOMCPServerFactory: Sendable { case "openaso://apps": let apps = try await service.listApps() return try Self.resourceResult(uri: parameters.uri, value: apps) + case "openaso://keywords/global": + let keywords = try service.listGlobalKeywords() + return try Self.resourceResult(uri: parameters.uri, value: keywords) default: return try await readAppResource(uri: parameters.uri) } @@ -118,6 +122,24 @@ struct OpenASOMCPServerFactory: Sendable { lookbackDays: 180 ) return try Self.resourceResult(uri: uri, value: competitors) + case .rankingHistory: + let rankings = try await service.listKeywordRankingHistory( + appStoreID: resource.appStoreID, + page: .init(limit: 100, cursor: nil) + ) + return try Self.resourceResult(uri: uri, value: rankings) + case .rankingResults: + let rankings = try await service.listKeywordRankingResults( + appStoreID: resource.appStoreID, + page: .init(limit: 100, cursor: nil) + ) + return try Self.resourceResult(uri: uri, value: rankings) + case .ratingHistory: + let ratings = try await service.listRatingHistory( + appStoreID: resource.appStoreID, + page: .init(limit: 100, cursor: nil) + ) + return try Self.resourceResult(uri: uri, value: ratings) } } @@ -194,6 +216,13 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "list_global_keywords": + let result = try service.listGlobalKeywords( + storefronts: arguments.stringArray("storefronts"), + platform: arguments.string("platform") + ) + return try Self.toolResult(result) + case "score_keywords": let result = try await service.scoreKeywords( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -211,6 +240,14 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "add_global_keywords": + let result = try service.addGlobalKeywords( + keywords: try arguments.requiredStringArray("keywords"), + storefronts: try arguments.requiredStringArray("storefronts"), + platform: arguments.string("platform") + ) + return try Self.toolResult(result) + case "update_keyword_notes": let result = try await service.updateKeywordNotes( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -221,6 +258,45 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "update_keyword_track": + let result = try await service.updateKeywordTrack( + trackIdentityKey: try arguments.requiredString("track_identity_key"), + keyword: arguments.string("keyword"), + storefront: arguments.string("storefront"), + platform: arguments.string("platform"), + notes: arguments.string("notes") + ) + return try Self.toolResult(result) + + case "delete_keyword_track": + let result = try await service.deleteKeywordTrack( + trackIdentityKey: try arguments.requiredString("track_identity_key") + ) + return try Self.toolResult(result) + + case "reset_keywords": + let result = try await service.resetKeywords( + appStoreID: try arguments.requiredInt64("appStoreID") + ) + return try Self.toolResult(result) + + case "update_global_keyword": + let result = try service.updateGlobalKeyword( + id: try arguments.requiredString("id"), + keyword: try arguments.requiredString("keyword"), + storefront: try arguments.requiredString("storefront"), + platform: arguments.string("platform") + ) + return try Self.toolResult(result) + + case "delete_global_keyword": + let result = service.deleteGlobalKeyword(id: try arguments.requiredString("id")) + return try Self.toolResult(result) + + case "reset_global_keywords": + let result = service.resetGlobalKeywords() + return try Self.toolResult(result) + case "list_screenshots": let result = try await service.listScreenshots( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -239,6 +315,13 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "compare_app_pricing": + let result = try await service.compareAppPricing( + appStoreIDs: try arguments.requiredInt64Array("appStoreIDs"), + storefronts: arguments.stringArray("storefronts") + ) + return try Self.toolResult(result) + case "fetch_website_markdown": let result = try await service.fetchWebsiteMarkdown(urlString: try arguments.requiredString("url")) return try Self.toolResult(result) @@ -290,6 +373,36 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "list_keyword_ranking_history": + let result = try await service.listKeywordRankingHistory( + appStoreID: try arguments.requiredInt64("appStoreID"), + storefronts: arguments.stringArray("storefronts"), + platform: arguments.string("platform"), + keyword: arguments.string("keyword"), + trackIdentityKey: arguments.string("track_identity_key"), + page: .init(limit: arguments.int("limit"), cursor: arguments.string("cursor")) + ) + return try Self.toolResult(result) + + case "list_keyword_ranking_results": + let result = try await service.listKeywordRankingResults( + appStoreID: try arguments.requiredInt64("appStoreID"), + storefronts: arguments.stringArray("storefronts"), + platform: arguments.string("platform"), + keyword: arguments.string("keyword"), + queryKey: arguments.string("query_key"), + page: .init(limit: arguments.int("limit"), cursor: arguments.string("cursor")) + ) + return try Self.toolResult(result) + + case "list_rating_history": + let result = try await service.listRatingHistory( + appStoreID: try arguments.requiredInt64("appStoreID"), + storefronts: arguments.stringArray("storefronts"), + page: .init(limit: arguments.int("limit"), cursor: arguments.string("cursor")) + ) + return try Self.toolResult(result) + case "refresh_keyword_metrics": let result = try await service.refreshKeywordMetrics( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -437,6 +550,9 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer, "cursor": .string]) { current, _ in current } ), readOnly: true), + tool("list_global_keywords", "List reusable global keyword templates.", schema( + optional: ["storefronts": .stringArray, "platform": .string] + ), readOnly: true), tool("score_keywords", "Classify tracked keywords into defend, attack, long-tail, brand, experimental, or noisy buckets using rank, popularity, and phrase-quality heuristics.", schema( required: ["appStoreID"], optional: commonAppFilters @@ -445,10 +561,38 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID", "keywords", "storefronts"], optional: ["appStoreID": .integer, "keywords": .stringArray, "storefronts": .stringArray, "platform": .string] ), readOnly: false, destructive: false, idempotent: true), + tool("add_global_keywords", "Add reusable global keyword templates.", schema( + required: ["keywords", "storefronts"], + optional: ["keywords": .stringArray, "storefronts": .stringArray, "platform": .string] + ), readOnly: false, destructive: false, idempotent: true), tool("update_keyword_notes", "Update notes for one tracked keyword.", schema( required: ["appStoreID", "keyword", "storefront", "notes"], optional: ["appStoreID": .integer, "keyword": .string, "storefront": .string, "platform": .string, "notes": .string] ), readOnly: false, destructive: false, idempotent: true), + tool("update_keyword_track", "Edit a per-app tracked keyword term, storefront, platform, or notes by track identity key. Ranking snapshots for the old target are cleared when the tracked query changes.", schema( + required: ["track_identity_key"], + optional: [ + "track_identity_key": .string, + "keyword": .string, + "storefront": .string, + "platform": .string, + "notes": .string + ] + ), readOnly: false, destructive: false, idempotent: true), + tool("delete_keyword_track", "Delete one per-app tracked keyword by track identity key.", schema( + required: ["track_identity_key"], + optional: ["track_identity_key": .string] + ), readOnly: false, destructive: true, idempotent: false), + tool("reset_keywords", "Delete all tracked keywords for one app.", appIDSchema, readOnly: false, destructive: true, idempotent: false), + tool("update_global_keyword", "Edit one reusable global keyword template by id.", schema( + required: ["id", "keyword", "storefront"], + optional: ["id": .string, "keyword": .string, "storefront": .string, "platform": .string] + ), readOnly: false, destructive: false, idempotent: true), + tool("delete_global_keyword", "Delete one reusable global keyword template by id.", schema( + required: ["id"], + optional: ["id": .string] + ), readOnly: false, destructive: true, idempotent: false), + tool("reset_global_keywords", "Delete all reusable global keyword templates.", schema(optional: [:]), readOnly: false, destructive: true, idempotent: false), tool("list_screenshots", "List stored App Store screenshot metadata.", schema( required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer, "cursor": .string]) { current, _ in current } @@ -457,6 +601,10 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID", "destination_directory_path"], optional: commonAppFilters.merging(["destination_directory_path": .string]) { current, _ in current } ), readOnly: false, destructive: false, idempotent: false, openWorld: true), + tool("compare_app_pricing", "Fetch public App Store app price and currency across storefronts for one or more apps, plus visible in-app purchase/subscription rows with inferred monthly/yearly cadence and localized USD comparison when public data allows it.", schema( + required: ["appStoreIDs"], + optional: ["appStoreIDs": .integerArray, "storefronts": .stringArray] + ), readOnly: true, openWorld: true), tool("fetch_website_markdown", "Fetch markdown for a website through markdown.new.", schema( required: ["url"], optional: ["url": .string] @@ -491,6 +639,33 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer]) { current, _ in current } ), readOnly: false, destructive: false, idempotent: false, openWorld: true), + tool("list_keyword_ranking_history", "List persisted per-app keyword rank snapshots with top ranking results.", schema( + required: ["appStoreID"], + optional: commonAppFilters.merging([ + "keyword": .string, + "track_identity_key": .string, + "limit": .integer, + "cursor": .string + ]) { current, _ in current } + ), readOnly: true), + tool("list_keyword_ranking_results", "List raw shared keyword ranking crawls and ranked apps for queries tracked by an app.", schema( + required: ["appStoreID"], + optional: commonAppFilters.merging([ + "keyword": .string, + "query_key": .string, + "limit": .integer, + "cursor": .string + ]) { current, _ in current } + ), readOnly: true), + tool("list_rating_history", "List persisted daily rating history for one app.", schema( + required: ["appStoreID"], + optional: [ + "appStoreID": .integer, + "storefronts": .stringArray, + "limit": .integer, + "cursor": .string + ] + ), readOnly: true), tool("refresh_keyword_metrics", "Refresh keyword popularity metrics when Apple Ads is configured; otherwise return actionable setup status per keyword.", schema( required: ["appStoreID"], optional: commonAppFilters @@ -541,22 +716,95 @@ private extension OpenASOMCPServerFactory { ] } - static let resources: [Resource] = [ - Resource( - name: "workspace_summary", - uri: "openaso://workspace/summary", - title: "Workspace Summary", - description: "Tracked app count and high-level app summaries.", - mimeType: jsonMimeType - ), - Resource( - name: "apps", - uri: "openaso://apps", - title: "Apps", - description: "Tracked apps with high-level metrics.", - mimeType: jsonMimeType - ) - ] + static func resources(apps: [OpenASOMCPAppSummary]) -> [Resource] { + var resources = [ + Resource( + name: "workspace_summary", + uri: "openaso://workspace/summary", + title: "Workspace Summary", + description: "Tracked app count and high-level app summaries.", + mimeType: jsonMimeType + ), + Resource( + name: "apps", + uri: "openaso://apps", + title: "Apps", + description: "Tracked apps with high-level metrics.", + mimeType: jsonMimeType + ), + Resource( + name: "global_keywords", + uri: "openaso://keywords/global", + title: "Global Keywords", + description: "Reusable global keyword templates.", + mimeType: jsonMimeType + ) + ] + + for app in apps { + let appID = app.appStoreID + let appName = app.name + resources.append(contentsOf: [ + Resource( + name: "app_\(appID)_overview", + uri: "openaso://apps/\(appID)", + title: "\(appName) Overview", + description: "Metadata, ratings, review summary, keyword summary, screenshots, and competitors.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_reviews", + uri: "openaso://apps/\(appID)/reviews", + title: "\(appName) Reviews", + description: "Stored App Store reviews.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_keywords", + uri: "openaso://apps/\(appID)/keywords", + title: "\(appName) Keywords", + description: "Tracked keywords with latest rank and metrics.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_ranking_history", + uri: "openaso://apps/\(appID)/ranking-history", + title: "\(appName) Ranking History", + description: "Persisted per-app keyword rank snapshots.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_ranking_results", + uri: "openaso://apps/\(appID)/ranking-results", + title: "\(appName) Ranking Results", + description: "Raw shared keyword ranking crawls for tracked queries.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_rating_history", + uri: "openaso://apps/\(appID)/rating-history", + title: "\(appName) Rating History", + description: "Persisted daily rating history.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_screenshots", + uri: "openaso://apps/\(appID)/screenshots", + title: "\(appName) Screenshots", + description: "Stored App Store screenshot metadata.", + mimeType: jsonMimeType + ), + Resource( + name: "app_\(appID)_competitors", + uri: "openaso://apps/\(appID)/competitors", + title: "\(appName) Competitors", + description: "Competitors derived from shared keyword rankings.", + mimeType: jsonMimeType + ) + ]) + } + return resources + } static let prompts: [Prompt] = [ prompt("review_theme_analysis", "Summarize review praise, complaints, feature requests, regressions, pricing objections, and version-specific issues."), @@ -724,6 +972,7 @@ private extension OpenASOMCPServerFactory { private enum JSONSchemaType: Sendable { case boolean case integer + case integerArray case string case stringArray @@ -733,6 +982,8 @@ private enum JSONSchemaType: Sendable { return ["type": "boolean"] case .integer: return ["type": "integer"] + case .integerArray: + return ["type": "array", "items": ["type": "integer"]] case .string: return ["type": "string"] case .stringArray: @@ -753,6 +1004,9 @@ private struct OpenASOMCPAppResource: Sendable { case keywords case screenshots case competitors + case rankingHistory + case rankingResults + case ratingHistory } let appStoreID: Int64 @@ -780,6 +1034,12 @@ private struct OpenASOMCPAppResource: Sendable { self.kind = .screenshots case "competitors": self.kind = .competitors + case "ranking-history": + self.kind = .rankingHistory + case "ranking-results": + self.kind = .rankingResults + case "rating-history": + self.kind = .ratingHistory default: return nil } @@ -810,6 +1070,19 @@ private extension Dictionary where Key == String, Value == MCP.Value { return Int64(intValue) } + func int64Array(_ key: String) -> [Int64]? { + self[key]?.arrayValue?.compactMap { value in + Int(value).map(Int64.init) + } + } + + func requiredInt64Array(_ key: String) throws -> [Int64] { + guard let values = int64Array(key), !values.isEmpty else { + throw MCPError.invalidParams("Missing required integer array argument: \(key)") + } + return values + } + func bool(_ key: String) -> Bool? { guard let value = self[key] else { return nil } return Bool(value) diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 0a1b711..25b3224 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -2,6 +2,8 @@ import Foundation import SwiftData final class OpenASOMCPService: Sendable { + private static let globalKeywordTemplatesKey = "globalTrackedKeywordTemplatesJSON" + private enum ResponseLimits { static let overviewStorefrontMetadata = 8 static let overviewRatings = 25 @@ -17,6 +19,7 @@ final class OpenASOMCPService: Sendable { static let maximumRankingAppLimit = 50 static let defaultKeywordRefreshTrackLimit = 20 static let maximumKeywordRefreshTrackLimit = 25 + static let keywordRefreshConcurrency = 4 static let keywordVerificationSearchBudget = 16 static let rankingSearchTimeoutNanoseconds: UInt64 = 20_000_000_000 static let bigAppRatingThreshold = 10_000 @@ -32,6 +35,9 @@ final class OpenASOMCPService: Sendable { static let maximumLocalizationCompetitorLimit = 20 static let localizationDescriptionCharacters = 1_200 static let localizationReleaseNotesCharacters = 800 + static let maximumPricingAppCount = 10 + static let pricingFetchConcurrency = 6 + static let planPriceFetchConcurrency = 4 } private static let localizationBaselineStorefront = "us" @@ -39,6 +45,24 @@ final class OpenASOMCPService: Sendable { "us", "jp", "cn", "gb", "de", "fr", "ca", "au", "kr", "br", "mx", "es", "it", "nl", "se", "ch", "tr", "in", "id", "sa", ] + private static let appStoreTaxInclusiveRatesByStorefront: [String: Double] = [ + "ae": 0.05, "al": 0.20, "am": 0.20, "ao": 0.14, "ar": 0.21, + "at": 0.20, "au": 0.10, "az": 0.18, "ba": 0.17, "bb": 0.175, + "be": 0.21, "bg": 0.20, "bh": 0.10, "bj": 0.18, "br": 0.17, + "by": 0.20, "ca": 0.05, "ch": 0.081, "cl": 0.19, "co": 0.19, + "cr": 0.13, "cy": 0.19, "cz": 0.21, "de": 0.19, "dk": 0.25, + "ee": 0.22, "eg": 0.14, "es": 0.21, "fi": 0.255, "fr": 0.20, + "gb": 0.20, "ge": 0.18, "gh": 0.15, "gr": 0.24, "hr": 0.25, + "hu": 0.27, "id": 0.11, "ie": 0.23, "in": 0.18, "is": 0.24, + "it": 0.22, "jp": 0.10, "ke": 0.16, "kr": 0.10, "kz": 0.12, + "lt": 0.21, "lu": 0.17, "lv": 0.21, "ma": 0.20, "md": 0.20, + "mt": 0.18, "mx": 0.16, "my": 0.08, "ng": 0.075, "nl": 0.21, + "no": 0.25, "nz": 0.15, "om": 0.05, "pe": 0.18, "ph": 0.12, + "pl": 0.23, "pt": 0.23, "ro": 0.19, "rs": 0.20, "sa": 0.15, + "se": 0.25, "sg": 0.09, "si": 0.22, "sk": 0.20, "th": 0.07, + "tr": 0.20, "tw": 0.05, "tz": 0.18, "ua": 0.20, "ug": 0.18, + "uy": 0.22, "vn": 0.10, "za": 0.15, + ] private let backgroundModelStore: BackgroundModelStore private let appResolver: any AppResolver @@ -384,6 +408,135 @@ final class OpenASOMCPService: Sendable { } } + func listGlobalKeywords( + storefronts: [String]? = nil, + platform: String? = nil + ) throws -> [OpenASOMCPGlobalKeywordTemplate] { + let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) + let platform = try platform.map(OpenASOMCPValidation.platform) + return Self.globalKeywordTemplates().filter { template in + if !storefronts.isEmpty && !storefronts.contains(template.storefront) { return false } + if let platform, template.platformRaw != platform.rawValue { return false } + return true + }.map(Self.globalKeywordTemplate) + } + + func addGlobalKeywords( + keywords: [String], + storefronts: [String], + platform: String? = nil + ) throws -> OpenASOMCPGlobalKeywordMutationResult { + let keywords = try OpenASOMCPValidation.keywords(keywords) + let storefronts = try OpenASOMCPValidation.storefronts(storefronts) + let platform = try OpenASOMCPValidation.platform(platform) + guard !storefronts.isEmpty else { + throw OpenASOError.providerUnavailable("Select at least one storefront.") + } + + var templates = Self.globalKeywordTemplates() + var existingIDs = Set(templates.map(\.id)) + var insertedCount = 0 + var skippedCount = 0 + + for storefront in storefronts { + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate( + term: keyword, + storefront: storefront, + platform: platform + ) + if existingIDs.insert(template.id).inserted { + templates.append(template) + insertedCount += 1 + } else { + skippedCount += 1 + } + } + } + + Self.setGlobalKeywordTemplates(templates) + return OpenASOMCPGlobalKeywordMutationResult( + templates: templates.map(Self.globalKeywordTemplate), + summary: OpenASOMCPMutationSummary( + inserted: insertedCount, + updated: 0, + skipped: skippedCount, + refreshed: 0, + failed: 0 + ) + ) + } + + func updateGlobalKeyword( + id: String, + keyword: String, + storefront: String, + platform: String? = nil + ) throws -> OpenASOMCPGlobalKeywordMutationResult { + let keyword = try OpenASOMCPValidation.keyword(keyword) + let storefront = try OpenASOMCPValidation.storefront(storefront) + let platform = try OpenASOMCPValidation.platform(platform) + var templates = Self.globalKeywordTemplates() + guard let index = templates.firstIndex(where: { $0.id == id }) else { + throw OpenASOError.appNotFound + } + let replacement = GlobalTrackedKeywordTemplate( + term: keyword, + storefront: storefront, + platform: platform, + createdAt: templates[index].createdAt + ) + guard !templates.enumerated().contains(where: { offset, template in + offset != index && template.id == replacement.id + }) else { + throw OpenASOError.providerUnavailable( + "That global keyword, country, and device already exist.") + } + templates[index] = replacement + Self.setGlobalKeywordTemplates(templates) + return OpenASOMCPGlobalKeywordMutationResult( + templates: templates.map(Self.globalKeywordTemplate), + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: 1, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + + func deleteGlobalKeyword(id: String) -> OpenASOMCPGlobalKeywordMutationResult { + let templates = Self.globalKeywordTemplates() + let retained = templates.filter { $0.id != id } + Self.setGlobalKeywordTemplates(retained) + return OpenASOMCPGlobalKeywordMutationResult( + templates: retained.map(Self.globalKeywordTemplate), + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: templates.count - retained.count, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + + func resetGlobalKeywords() -> OpenASOMCPGlobalKeywordMutationResult { + let templates = Self.globalKeywordTemplates() + Self.setGlobalKeywordTemplates([]) + return OpenASOMCPGlobalKeywordMutationResult( + templates: [], + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: templates.count, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + func scoreKeywords( appStoreID: Int64, storefronts: [String]? = nil, @@ -547,6 +700,133 @@ final class OpenASOMCPService: Sendable { } } + func updateKeywordTrack( + trackIdentityKey: String, + keyword: String? = nil, + storefront: String? = nil, + platform: String? = nil, + notes: String? = nil + ) async throws -> OpenASOMCPKeywordTrackMutationResult { + let keyword = try keyword.map(OpenASOMCPValidation.keyword) + let storefront = try storefront.map(OpenASOMCPValidation.storefront) + let platform = try platform.map(OpenASOMCPValidation.platform) + return try await backgroundModelStore.write { modelContext in + var descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.identityKey == trackIdentityKey + } + ) + descriptor.fetchLimit = 1 + guard let track = try modelContext.fetch(descriptor).first else { + throw OpenASOError.appNotFound + } + + let newKeyword = keyword ?? track.term + let newStorefront = storefront ?? track.storefront + let newPlatform = platform ?? track.platform + let newIdentityKey = TrackedAppKeyword.makeIdentityKey( + appStoreID: track.appStoreID, + term: newKeyword, + storefront: newStorefront, + platform: newPlatform + ) + if newIdentityKey != track.identityKey { + var duplicateDescriptor = FetchDescriptor( + predicate: #Predicate { candidate in + candidate.identityKey == newIdentityKey + } + ) + duplicateDescriptor.fetchLimit = 1 + guard try modelContext.fetch(duplicateDescriptor).isEmpty else { + throw OpenASOError.providerUnavailable( + "That keyword, country, and device already exist for this app.") + } + Self.deleteSnapshots(for: track, in: modelContext) + track.rankingAppCount = nil + track.lastRefreshAt = nil + track.statusMessage = "Edited. Refresh to collect rankings for the new keyword." + } + + track.term = newKeyword + track.storefront = newStorefront + track.platform = newPlatform + track.identityKey = newIdentityKey + track.query = try KeywordQuery.fetchOrInsert( + term: newKeyword, + storefront: newStorefront, + platform: newPlatform, + in: modelContext + ) + if let notes { + track.notes = notes + } + + let metrics = try Self.metricsByQueryKey(queryKeys: [track.queryKey], in: modelContext) + return OpenASOMCPKeywordTrackMutationResult( + track: Self.keywordSummary(track: track, metrics: metrics[track.queryKey]), + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: 1, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + } + + func deleteKeywordTrack(trackIdentityKey: String) async throws -> OpenASOMCPKeywordTrackMutationResult { + try await backgroundModelStore.write { modelContext in + var descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.identityKey == trackIdentityKey + } + ) + descriptor.fetchLimit = 1 + guard let track = try modelContext.fetch(descriptor).first else { + throw OpenASOError.appNotFound + } + Self.deleteSnapshots(for: track, in: modelContext) + modelContext.delete(track) + return OpenASOMCPKeywordTrackMutationResult( + track: nil, + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: 1, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + } + + func resetKeywords(appStoreID: Int64) async throws -> OpenASOMCPKeywordTrackMutationResult { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + return try await backgroundModelStore.write { modelContext in + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.appStoreID == appStoreID + } + ) + let tracks = try modelContext.fetch(descriptor) + for track in tracks { + Self.deleteSnapshots(for: track, in: modelContext) + modelContext.delete(track) + } + return OpenASOMCPKeywordTrackMutationResult( + track: nil, + summary: OpenASOMCPMutationSummary( + inserted: 0, + updated: tracks.count, + skipped: 0, + refreshed: 0, + failed: 0 + ) + ) + } + } + func listScreenshots( appStoreID: Int64, storefronts: [String]? = nil, @@ -639,6 +919,69 @@ final class OpenASOMCPService: Sendable { ) } + func compareAppPricing( + appStoreIDs: [Int64], + storefronts: [String]? = nil, + progress: (@Sendable (Int, Int, Int) async -> Void)? = nil + ) async throws -> OpenASOMCPAppPricingComparison { + let appStoreIDs = try appStoreIDs + .prefix(ResponseLimits.maximumPricingAppCount) + .map(OpenASOMCPValidation.appStoreID) + guard !appStoreIDs.isEmpty else { + throw OpenASOError.invalidAppStoreID + } + + let requestedStorefronts = try OpenASOMCPValidation.storefronts(storefronts) + let storefronts = try await pricingStorefronts(requestedStorefronts) + let requests = appStoreIDs.flatMap { appStoreID in + storefronts.map { storefront in + AppPricingFetchRequest(appStoreID: appStoreID, storefront: storefront) + } + } + await progress?(0, requests.count, 0) + + let prices = await Self.fetchAppPricing( + requests: requests, + httpClient: httpClient, + concurrency: ResponseLimits.pricingFetchConcurrency + ) + let currencyByRequest = Dictionary( + uniqueKeysWithValues: prices.compactMap { price -> (AppPricingFetchRequest, String)? in + guard let currency = price.currency else { return nil } + return ( + AppPricingFetchRequest( + appStoreID: Int64(price.appStoreID) ?? 0, + storefront: price.storefront + ), + currency + ) + }.filter { $0.0.appStoreID > 0 } + ) + let exchangeRates = await Self.fetchUSDExchangeRates(httpClient: httpClient) + let plans = await Self.fetchAppStorefrontPlans( + requests: requests, + currenciesByRequest: currencyByRequest, + exchangeRatesPerUSD: exchangeRates, + httpClient: httpClient, + concurrency: ResponseLimits.planPriceFetchConcurrency, + progress: progress + ) + return OpenASOMCPAppPricingComparison( + generatedAt: now(), + appStoreIDs: appStoreIDs.map(String.init), + storefronts: storefronts, + prices: prices, + plans: plans, + localizedPricing: Self.localizedPricingComparisons(from: plans), + notes: [ + "Base app prices come from the public iTunes lookup endpoint.", + "Plan prices come from the public App Store page's visible In-App Purchases rows. Apple does not reliably expose full subscription duration, intro offer, eligibility, or complete App Store Connect price schedule data through this public page.", + "USD-normalized plan comparisons use Frankfurter's ECB-backed exchange-rate API when available; if rates are unavailable, local display prices are still returned but USD deltas are nil.", + "Localized price deltas are tax-adjusted for storefronts where App Store customer prices are typically tax-inclusive. Raw customer-price deltas are still returned separately." + ] + ) + } + func fetchWebsiteMarkdown(urlString: String) async throws -> OpenASOMCPWebsiteMarkdownResult { let sourceURL = try OpenASOMCPValidation.webURL(urlString) let markdownURL = try Self.markdownNewURL(for: sourceURL) @@ -823,22 +1166,11 @@ final class OpenASOMCPService: Sendable { ) } - var fetched: [(RankingRefreshRequest, SearchRankingPage?, OpenASOError?)] = [] - for request in requestBatch.requests { - do { - let page = try await Self.withRankingSearchTimeout { - try await provider.search( - keyword: request.term, - storefrontCode: request.storefront, - platform: request.platform, - limit: resultLimit - ) - } - fetched.append((request, page, nil)) - } catch { - fetched.append((request, nil, OpenASOError.map(error))) - } - } + let fetched = await Self.fetchRankingPages( + requests: requestBatch.requests, + provider: provider, + limit: resultLimit + ) let fetchedResults = fetched return try await backgroundModelStore.write { modelContext in @@ -907,6 +1239,149 @@ final class OpenASOMCPService: Sendable { } } + func listKeywordRankingHistory( + appStoreID: Int64, + storefronts: [String]? = nil, + platform: String? = nil, + keyword: String? = nil, + trackIdentityKey: String? = nil, + page: OpenASOMCPPageRequest = OpenASOMCPPageRequest(limit: nil, cursor: nil) + ) async throws -> OpenASOMCPPage { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) + let platform = try platform.map(OpenASOMCPValidation.platform) + let keyword = try keyword.map(OpenASOMCPValidation.keyword) + return try await backgroundModelStore.read { modelContext in + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.appStoreID == appStoreID + }, + sortBy: [ + SortDescriptor(\.storefront, order: .forward), + SortDescriptor(\.term, order: .forward), + ] + ) + let snapshots = try modelContext.fetch(descriptor) + .filter { track in + if let trackIdentityKey, track.identityKey != trackIdentityKey { return false } + if !storefronts.isEmpty && !storefronts.contains(track.storefront) { return false } + if let platform, track.platform != platform { return false } + if let keyword, track.term.localizedCaseInsensitiveCompare(keyword) != .orderedSame { + return false + } + return true + } + .flatMap { track in + track.snapshots.map { Self.keywordRankingSnapshot($0, track: track) } + } + .sorted { lhs, rhs in + if lhs.searchedAt == rhs.searchedAt { + return lhs.trackIdentityKey < rhs.trackIdentityKey + } + return lhs.searchedAt > rhs.searchedAt + } + let total = snapshots.count + let items = Array(snapshots.dropFirst(page.offset).prefix(page.limit)) + return OpenASOMCPPage( + items: items, + nextCursor: OpenASOMCPValidation.nextCursor( + offset: page.offset, + limit: page.limit, + returnedCount: items.count, + totalCount: total + ), + total: total + ) + } + } + + func listKeywordRankingResults( + appStoreID: Int64, + storefronts: [String]? = nil, + platform: String? = nil, + keyword: String? = nil, + queryKey: String? = nil, + page: OpenASOMCPPageRequest = OpenASOMCPPageRequest(limit: nil, cursor: nil) + ) async throws -> OpenASOMCPPage { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) + let platform = try platform.map(OpenASOMCPValidation.platform) + let keyword = try keyword.map(OpenASOMCPValidation.keyword) + return try await backgroundModelStore.read { modelContext in + let trackedQueryKeys = try Self.trackedQueryKeys( + appStoreID: appStoreID, + storefronts: storefronts, + platform: platform, + keyword: keyword, + queryKey: queryKey, + in: modelContext + ) + guard !trackedQueryKeys.isEmpty else { + return OpenASOMCPPage(items: [], nextCursor: nil, total: 0) + } + + let descriptor = FetchDescriptor( + predicate: #Predicate { crawl in + trackedQueryKeys.contains(crawl.queryKey) + }, + sortBy: [SortDescriptor(\.observedAt, order: .reverse)] + ) + let crawls = try modelContext.fetch(descriptor).filter { crawl in + if !storefronts.isEmpty && !storefronts.contains(crawl.storefront) { return false } + if let platform, crawl.platform != platform { return false } + if let keyword, crawl.keyword.localizedCaseInsensitiveCompare(keyword) != .orderedSame { + return false + } + if let queryKey, crawl.queryKey != queryKey { return false } + return true + }.map(Self.keywordRankingCrawl) + let total = crawls.count + let items = Array(crawls.dropFirst(page.offset).prefix(page.limit)) + return OpenASOMCPPage( + items: items, + nextCursor: OpenASOMCPValidation.nextCursor( + offset: page.offset, + limit: page.limit, + returnedCount: items.count, + totalCount: total + ), + total: total + ) + } + } + + func listRatingHistory( + appStoreID: Int64, + storefronts: [String]? = nil, + page: OpenASOMCPPageRequest = OpenASOMCPPageRequest(limit: nil, cursor: nil) + ) async throws -> OpenASOMCPPage { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) + return try await backgroundModelStore.read { modelContext in + let descriptor = FetchDescriptor( + predicate: #Predicate { rating in + rating.appStoreID == appStoreID + }, + sortBy: [SortDescriptor(\.observedAt, order: .reverse)] + ) + let ratings = try modelContext.fetch(descriptor).filter { rating in + storefronts.isEmpty || storefronts.contains(rating.storefront) + }.map(Self.ratingHistoryItem) + let total = ratings.count + let items = Array(ratings.dropFirst(page.offset).prefix(page.limit)) + return OpenASOMCPPage( + items: items, + nextCursor: OpenASOMCPValidation.nextCursor( + offset: page.offset, + limit: page.limit, + returnedCount: items.count, + totalCount: total + ), + total: total + ) + } + } + func refreshKeywordMetrics( appStoreID: Int64, storefronts: [String]? = nil, @@ -1462,178 +1937,960 @@ final class OpenASOMCPService: Sendable { )) } } - let completed = exports.reduce(0) { $0 + $1.completed.count } - let failed = exports.reduce(0) { $0 + $1.failed.count } - let notes = - completed == 0 - ? [ - "No competitor screenshots were exported. Run discover_keyword_landscape first so ranking metadata and screenshots can be stored." - ] - : [ - "Screenshots were exported for agent-side visual analysis. OpenASO does not perform OCR or vision captioning in-process." - ] - return OpenASOMCPCompetitorScreenshotExportResult( - competitors: competitors, - summary: OpenASOMCPMutationSummary( - inserted: 0, updated: 0, skipped: 0, refreshed: completed, failed: failed + failures.count), - exports: exports, - failures: failures, - notes: notes - ) + let completed = exports.reduce(0) { $0 + $1.completed.count } + let failed = exports.reduce(0) { $0 + $1.failed.count } + let notes = + completed == 0 + ? [ + "No competitor screenshots were exported. Run discover_keyword_landscape first so ranking metadata and screenshots can be stored." + ] + : [ + "Screenshots were exported for agent-side visual analysis. OpenASO does not perform OCR or vision captioning in-process." + ] + return OpenASOMCPCompetitorScreenshotExportResult( + competitors: competitors, + summary: OpenASOMCPMutationSummary( + inserted: 0, updated: 0, skipped: 0, refreshed: completed, failed: failed + failures.count), + exports: exports, + failures: failures, + notes: notes + ) + } + + func getLocalizationResearchContext( + appStoreID: Int64, + storefronts: [String]? = nil, + platform: String? = nil, + competitorLimit: Int? = nil, + includeTargetApp: Bool = true, + refreshMissingMetadata: Bool = true, + destinationDirectoryPath: String? = nil + ) async throws -> OpenASOMCPLocalizationResearchContext { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let requestedStorefronts = try OpenASOMCPValidation.storefronts(storefronts) + let storefronts = + requestedStorefronts.isEmpty ? Self.defaultLocalizationStorefronts : requestedStorefronts + let analysisStorefronts = Self.storefrontsIncludingLocalizationBaseline(storefronts) + let platform = try OpenASOMCPValidation.platform(platform) + let competitorLimit = OpenASOMCPValidation.cappedLimit( + competitorLimit, + default: ResponseLimits.defaultLocalizationCompetitorLimit, + maximum: ResponseLimits.maximumLocalizationCompetitorLimit + ) + + let competitors = try await listCompetitors( + appStoreID: appStoreID, + storefronts: analysisStorefronts, + platform: platform.rawValue, + limit: competitorLimit, + evidenceLimit: ResponseLimits.overviewCompetitorEvidence + ) + + var subjects: [(role: String, appStoreID: Int64)] = [] + if includeTargetApp { + subjects.append(("target", appStoreID)) + } + for competitor in competitors { + guard let competitorID = Int64(competitor.appStoreID), + !subjects.contains(where: { $0.appStoreID == competitorID }) + else { + continue + } + subjects.append(("competitor", competitorID)) + } + + var fetchErrors: [OpenASOMCPLocalizationFetchError] = [] + var summariesByAppID: [Int64: OpenASOMCPAppSummary] = [:] + for subject in subjects { + do { + summariesByAppID[subject.appStoreID] = try await appSummaryResolvingCatalog( + appStoreID: subject.appStoreID, + storefront: Self.localizationBaselineStorefront + ) + } catch { + fetchErrors.append( + OpenASOMCPLocalizationFetchError( + appStoreID: String(subject.appStoreID), + storefront: Self.localizationBaselineStorefront, + error: OpenASOMCPErrorDTO(OpenASOError.map(error)) + )) + } + } + + if refreshMissingMetadata { + for subject in subjects where summariesByAppID[subject.appStoreID] != nil { + fetchErrors.append( + contentsOf: try await refreshMissingLocalizationMetadata( + appStoreID: subject.appStoreID, + storefronts: analysisStorefronts + )) + } + } + + var screenshotExportsByAppID: [Int64: OpenASOMCPScreenshotExportResult] = [:] + if let destinationDirectoryPath { + _ = try OpenASOMCPValidation.writableDirectory(destinationDirectoryPath, createIfNeeded: true) + for subject in subjects where summariesByAppID[subject.appStoreID] != nil { + screenshotExportsByAppID[subject.appStoreID] = try await exportScreenshots( + appStoreID: subject.appStoreID, + storefronts: analysisStorefronts, + platform: platform.rawValue, + destinationDirectoryPath: destinationDirectoryPath + ) + } + } + + let finalSubjects = subjects + let finalSummariesByAppID = summariesByAppID + let finalScreenshotExportsByAppID = screenshotExportsByAppID + let appContexts = try await backgroundModelStore.read { modelContext in + let languageCodesByStorefront = try Self.languageCodesByStorefront(in: modelContext) + return try finalSubjects.compactMap { subject -> OpenASOMCPLocalizationAppContext? in + guard + let storeApp = try Self.fetchStoreApp(appStoreID: subject.appStoreID, in: modelContext), + let app = finalSummariesByAppID[subject.appStoreID] + else { + return nil + } + return Self.localizationAppContext( + role: subject.role, + app: app, + storeApp: storeApp, + storefronts: storefronts, + baselineStorefront: Self.localizationBaselineStorefront, + platform: platform, + languageCodesByStorefront: languageCodesByStorefront, + screenshotExport: finalScreenshotExportsByAppID[subject.appStoreID] + ) + } + } + + return OpenASOMCPLocalizationResearchContext( + appStoreID: String(appStoreID), + generatedAt: now(), + baselineStorefront: Self.localizationBaselineStorefront, + storefronts: storefronts, + platform: platform.rawValue, + apps: appContexts, + notes: Self.localizationResearchNotes( + refreshMissingMetadata: refreshMissingMetadata, + destinationDirectoryPath: destinationDirectoryPath + ), + errors: fetchErrors + ) + } + + private func requireRankingProvider() throws -> any SearchRankingProvider { + guard let rankingProvider else { + throw OpenASOError.providerUnavailable( + "Ranking search is not configured for this MCP server.") + } + return rankingProvider + } + + static func withRankingSearchTimeout( + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: ResponseLimits.rankingSearchTimeoutNanoseconds) + throw OpenASOError.providerUnavailable( + "Ranking search timed out after 5 seconds. Reduce the keyword/storefront batch size and retry." + ) + } + + guard let result = try await group.next() else { + throw OpenASOError.providerUnavailable("Ranking search did not return a result.") + } + group.cancelAll() + return result + } + } + + private static func fetchRankingPages( + requests: [RankingRefreshRequest], + provider: any SearchRankingProvider, + limit: Int + ) async -> [(RankingRefreshRequest, SearchRankingPage?, OpenASOError?)] { + guard !requests.isEmpty else { return [] } + + return await withTaskGroup( + of: (Int, RankingRefreshRequest, SearchRankingPage?, OpenASOError?).self, + returning: [(RankingRefreshRequest, SearchRankingPage?, OpenASOError?)].self + ) { group in + var nextRequestIndex = 0 + var activeFetchCount = 0 + var results: [(Int, RankingRefreshRequest, SearchRankingPage?, OpenASOError?)] = [] + results.reserveCapacity(requests.count) + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < ResponseLimits.keywordRefreshConcurrency, + nextRequestIndex < requests.count + else { + return + } + + let index = nextRequestIndex + let request = requests[index] + nextRequestIndex += 1 + activeFetchCount += 1 + group.addTask { + do { + let page = try await Self.withRankingSearchTimeout { + try await provider.search( + keyword: request.term, + storefrontCode: request.storefront, + platform: request.platform, + limit: limit + ) + } + return (index, request, page, nil) + } catch { + return (index, request, nil, OpenASOError.map(error)) + } + } + } + + for _ in 0.. [String] { + if !requestedStorefronts.isEmpty { + return requestedStorefronts + } + + let storefronts = try await backgroundModelStore.read { modelContext in + try modelContext.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.code, order: .forward)]) + ).map(\.code) + } + if !storefronts.isEmpty { + return storefronts + } + + if let bundledStorefronts = try? StorefrontCatalog.bundledStorefrontCodes(), + !bundledStorefronts.isEmpty + { + return bundledStorefronts + } + + return Self.defaultLocalizationStorefronts + } + + private static func fetchAppPricing( + requests: [AppPricingFetchRequest], + httpClient: any HTTPClient, + concurrency: Int + ) async -> [OpenASOMCPAppStorefrontPrice] { + guard !requests.isEmpty else { return [] } + + return await withTaskGroup( + of: (Int, OpenASOMCPAppStorefrontPrice).self, + returning: [OpenASOMCPAppStorefrontPrice].self + ) { group in + var nextRequestIndex = 0 + var activeFetchCount = 0 + var results: [(Int, OpenASOMCPAppStorefrontPrice)] = [] + results.reserveCapacity(requests.count) + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < concurrency, + nextRequestIndex < requests.count + else { + return + } + + let index = nextRequestIndex + let request = requests[index] + nextRequestIndex += 1 + activeFetchCount += 1 + group.addTask { + (index, await appPrice(for: request, httpClient: httpClient)) + } + } + + for _ in 0.. OpenASOMCPAppStorefrontPrice { + do { + var components = URLComponents(string: "https://itunes.apple.com/lookup")! + components.queryItems = [ + URLQueryItem(name: "id", value: String(request.appStoreID)), + URLQueryItem(name: "country", value: request.storefront) + ] + guard let url = components.url else { + throw OpenASOError.unexpectedResponse + } + + var urlRequest = URLRequest(url: url) + urlRequest.timeoutInterval = 20 + let data = try await validatedData(for: urlRequest, using: httpClient) + let response = try JSONDecoder().decode(AppPricingLookupResponse.self, from: data) + guard let payload = response.results.first else { + throw OpenASOError.appNotFound + } + + return OpenASOMCPAppStorefrontPrice( + appStoreID: String(request.appStoreID), + storefront: request.storefront, + currency: payload.currency, + price: payload.price, + formattedPrice: payload.formattedPrice, + isFree: payload.price.map { $0 == 0 }, + error: nil + ) + } catch { + return OpenASOMCPAppStorefrontPrice( + appStoreID: String(request.appStoreID), + storefront: request.storefront, + currency: nil, + price: nil, + formattedPrice: nil, + isFree: nil, + error: OpenASOMCPErrorDTO(OpenASOError.map(error)) + ) + } + } + + static func fetchUSDExchangeRates(httpClient: any HTTPClient) async -> [String: Double] { + guard let url = URL(string: "https://api.frankfurter.app/latest?from=USD") else { return [:] } + do { + let request = URLRequest(url: url, timeoutInterval: 20) + let data = try await validatedData(for: request, using: httpClient) + let response = try JSONDecoder().decode(USDExchangeRateResponse.self, from: data) + return response.rates + } catch { + return [:] + } + } + + static func fetchAppStorefrontPlans( + requests: [AppPricingFetchRequest], + currenciesByRequest: [AppPricingFetchRequest: String], + exchangeRatesPerUSD: [String: Double], + httpClient: any HTTPClient, + concurrency: Int, + progress: (@Sendable (Int, Int, Int) async -> Void)? = nil + ) async -> [OpenASOMCPAppStorefrontPlan] { + guard !requests.isEmpty else { return [] } + + return await withTaskGroup( + of: (Int, [OpenASOMCPAppStorefrontPlan]).self, + returning: [OpenASOMCPAppStorefrontPlan].self + ) { group in + var nextRequestIndex = 0 + var activeFetchCount = 0 + var completedFetchCount = 0 + var failureCount = 0 + var results: [(Int, [OpenASOMCPAppStorefrontPlan])] = [] + results.reserveCapacity(requests.count) + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < concurrency, + nextRequestIndex < requests.count + else { + return + } + + let index = nextRequestIndex + let request = requests[index] + nextRequestIndex += 1 + activeFetchCount += 1 + group.addTask { + ( + index, + await appStorefrontPlans( + for: request, + currency: currenciesByRequest[request], + exchangeRatesPerUSD: exchangeRatesPerUSD, + httpClient: httpClient + ) + ) + } + } + + for _ in 0.. [OpenASOMCPAppStorefrontPlan] { + do { + guard let url = URL(string: "https://apps.apple.com/\(request.storefront)/app/id\(request.appStoreID)") else { + throw OpenASOError.invalidAppStoreID + } + var urlRequest = URLRequest(url: url, timeoutInterval: 20) + urlRequest.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + urlRequest.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept" + ) + let data = try await validatedData(for: urlRequest, using: httpClient) + guard let html = String(data: data, encoding: .utf8) else { + throw OpenASOError.decodingFailed + } + let rows = try inAppPurchaseRows(fromAppStoreHTML: html) + let parsedRows = rows.enumerated().map { index, row in + let parsedPrice = parsedPlanPrice(row.displayPrice, currency: currency) + return AppStorefrontParsedPlanRow( + index: index, + row: row, + price: parsedPrice.amount, + currency: parsedPrice.currency, + priceUSD: usdValue( + amount: parsedPrice.amount, + currency: parsedPrice.currency, + exchangeRatesPerUSD: exchangeRatesPerUSD + ), + explicitBillingCadence: inferredBillingCadence(name: row.name) + ) + } + let inferredCadences = inferredBillingCadences(for: parsedRows) + return parsedRows.map { parsedRow in + let row = parsedRow.row + return OpenASOMCPAppStorefrontPlan( + appStoreID: String(request.appStoreID), + storefront: request.storefront, + name: row.name, + displayPrice: row.displayPrice, + currency: parsedRow.currency, + price: parsedRow.price, + priceUSD: parsedRow.priceUSD, + billingCadence: inferredCadences[parsedRow.index], + source: "app_store_page_in_app_purchases", + error: nil + ) + } + } catch { + return [ + OpenASOMCPAppStorefrontPlan( + appStoreID: String(request.appStoreID), + storefront: request.storefront, + name: "", + displayPrice: "", + currency: currency, + price: nil, + priceUSD: nil, + billingCadence: nil, + source: "app_store_page_in_app_purchases", + error: OpenASOMCPErrorDTO(OpenASOError.map(error)) + ) + ] + } + } + + static func localizedPricingComparisons( + from plans: [OpenASOMCPAppStorefrontPlan] + ) -> [OpenASOMCPLocalizedPricingComparison] { + let successfulPlans = plans.filter { $0.error == nil && !$0.name.isEmpty } + let usPlansByKey = Dictionary(grouping: successfulPlans.filter { $0.storefront == "us" }) { + localizedPricingPlanKey($0) + }.compactMapValues { plans in + plans.first { $0.priceUSD != nil } ?? plans.first + } + let adjustmentFactorsByStorefront = storefrontAdjustmentFactors( + plans: successfulPlans, + usPlansByKey: usPlansByKey + ) + + return successfulPlans.compactMap { plan -> OpenASOMCPLocalizedPricingComparison? in + guard plan.storefront != "us", + let usPlan = usPlansByKey[localizedPricingPlanKey(plan)] + else { + return nil + } + let taxRate = appStoreTaxInclusiveRate(for: plan.storefront) + let adjustedPriceUSD = taxAdjustedPriceUSD(plan.priceUSD, storefront: plan.storefront) + let usAdjustedPriceUSD = taxAdjustedPriceUSD(usPlan.priceUSD, storefront: usPlan.storefront) + let rawDifference = percentDifference(plan.priceUSD, baseline: usPlan.priceUSD) + let factor = priceFactor(adjustedPriceUSD, baseline: usAdjustedPriceUSD) + let storefrontAdjustmentFactor = adjustmentFactorsByStorefront[storefrontAdjustmentKey(plan)] + let storefrontAdjustmentDifference = storefrontAdjustmentFactor.map { ($0 - 1) * 100 } + let taxAdjustedDifference = percentDifference(adjustedPriceUSD, baseline: usAdjustedPriceUSD) + let difference = appleAdjustedPercentDifference( + factor: factor, + storefrontAdjustmentFactor: storefrontAdjustmentFactor + ) ?? taxAdjustedDifference + return OpenASOMCPLocalizedPricingComparison( + appStoreID: plan.appStoreID, + planName: plan.name, + storefront: plan.storefront, + displayPrice: plan.displayPrice, + currency: plan.currency, + price: plan.price, + priceUSD: plan.priceUSD, + taxAdjustedPriceUSD: adjustedPriceUSD, + estimatedTaxRate: taxRate, + priceIncludesTax: taxRate != nil, + usDisplayPrice: usPlan.displayPrice, + usPrice: usPlan.price, + usPriceUSD: usPlan.priceUSD, + usTaxAdjustedPriceUSD: usAdjustedPriceUSD, + rawPercentDifferenceFromUS: rawDifference, + appleStorefrontAdjustmentPercentFromUS: storefrontAdjustmentDifference, + percentDifferenceFromUS: difference, + isLowerThanUS: difference.map { $0 < -1 }, + isHigherThanUS: difference.map { $0 > 1 } + ) + } + } + + private static func storefrontAdjustmentFactors( + plans: [OpenASOMCPAppStorefrontPlan], + usPlansByKey: [String: OpenASOMCPAppStorefrontPlan] + ) -> [String: Double] { + let candidates = plans.compactMap { plan -> LocalizedPricingAdjustmentCandidate? in + guard normalizedPricingStorefront(plan.storefront) != "us", + let usPlan = usPlansByKey[localizedPricingPlanKey(plan)], + let cadence = normalizedPricingCadence(plan.billingCadence), + let factor = priceFactor( + taxAdjustedPriceUSD(plan.priceUSD, storefront: plan.storefront), + baseline: taxAdjustedPriceUSD(usPlan.priceUSD, storefront: usPlan.storefront) + ) + else { + return nil + } + return LocalizedPricingAdjustmentCandidate( + appStoreID: plan.appStoreID, + storefront: normalizedPricingStorefront(plan.storefront), + cadence: cadence, + factor: factor + ) + } + + let reliableCandidates = Dictionary(grouping: candidates) { candidate in + [candidate.appStoreID, candidate.storefront].joined(separator: "::") + }.values.flatMap { appStorefrontCandidates -> [LocalizedPricingAdjustmentCandidate] in + let cadences = Set(appStorefrontCandidates.map(\.cadence)) + guard appStorefrontCandidates.count >= 2, cadences.count >= 2 else { + return [] + } + let center = median(appStorefrontCandidates.map(\.factor)) + guard center > 0 else { return [] } + let largestDeviation = appStorefrontCandidates + .map { abs(($0.factor / center) - 1) } + .max() ?? 0 + return largestDeviation <= 0.075 ? appStorefrontCandidates : [] + } + + let factorsByStorefront = Dictionary(grouping: reliableCandidates) { candidate in + [candidate.storefront, candidate.cadence].joined(separator: "::") + } + + return factorsByStorefront.mapValues { values in + median(values.map(\.factor)) + } + } + + private static func appleAdjustedPercentDifference( + factor: Double?, + storefrontAdjustmentFactor: Double? + ) -> Double? { + guard let factor, + let storefrontAdjustmentFactor, + storefrontAdjustmentFactor > 0 + else { + return nil + } + let difference = ((factor / storefrontAdjustmentFactor) - 1) * 100 + return abs(difference) <= 2.5 ? 0 : difference + } + + private static func priceFactor(_ value: Double?, baseline: Double?) -> Double? { + guard let value, + let baseline, + baseline > 0 + else { + return nil + } + return value / baseline + } + + private static func median(_ values: [Double]) -> Double { + let sorted = values.sorted() + guard !sorted.isEmpty else { return 1 } + let middle = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[middle - 1] + sorted[middle]) / 2 + } + return sorted[middle] + } + + private static func percentDifference(_ value: Double?, baseline: Double?) -> Double? { + guard let value, + let baseline, + baseline > 0 + else { + return nil + } + return ((value - baseline) / baseline) * 100 + } + + private static func taxAdjustedPriceUSD(_ value: Double?, storefront: String) -> Double? { + guard let value else { return nil } + guard let taxRate = appStoreTaxInclusiveRate(for: storefront) else { + return value + } + return value / (1 + taxRate) + } + + private static func appStoreTaxInclusiveRate(for storefront: String) -> Double? { + appStoreTaxInclusiveRatesByStorefront[ + normalizedPricingStorefront(storefront) + ] + } + + private static func normalizedPricingStorefront(_ storefront: String) -> String { + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private struct LocalizedPricingAdjustmentCandidate { + let appStoreID: String + let storefront: String + let cadence: String + let factor: Double + } + + private static func inAppPurchaseRows(fromAppStoreHTML html: String) throws -> [AppStorefrontPlanRow] { + guard let serializedData = AppStoreWebMetadataProvider.serializedServerData(from: html) else { + throw OpenASOError.decodingFailed + } + let jsonData = Data(AppStoreWebMetadataProvider.htmlDecoded(serializedData).utf8) + guard let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw OpenASOError.decodingFailed + } + var rows: [AppStorefrontPlanRow] = [] + collectInAppPurchaseRows(from: json, into: &rows) + var seen = Set() + return rows.filter { row in + !isConsumablePricingRow(row) + && seen.insert("\(normalizedPricingPlanName(row.name))::\(row.displayPrice)").inserted + } + } + + private static func collectInAppPurchaseRows(from value: Any, into rows: inout [AppStorefrontPlanRow]) { + if let dictionary = value as? [String: Any] { + if isInAppPurchasesSectionTitle(dictionary["title"] as? String) { + if let pairs = dictionary["items_V3"] as? [[String: Any]] { + for pair in pairs { + guard let name = pair["leadingText"] as? String, + let displayPrice = pair["trailingText"] as? String + else { + continue + } + rows.append(AppStorefrontPlanRow(name: name, displayPrice: displayPrice)) + } + } + if let items = dictionary["items"] as? [[String: Any]] { + for item in items { + guard let textPairs = item["textPairs"] as? [[String]] else { continue } + for pair in textPairs where pair.count >= 2 { + rows.append(AppStorefrontPlanRow(name: pair[0], displayPrice: pair[1])) + } + } + } + } + for child in dictionary.values { + collectInAppPurchaseRows(from: child, into: &rows) + } + } else if let array = value as? [Any] { + for child in array { + collectInAppPurchaseRows(from: child, into: &rows) + } + } + } + + private static func parsedPlanPrice( + _ displayPrice: String, + currency fallbackCurrency: String? + ) -> (amount: Double?, currency: String?) { + let amountString = displayPrice + .replacingOccurrences(of: "\u{00A0}", with: " ") + .components(separatedBy: CharacterSet(charactersIn: "0123456789.,").inverted) + .compactMap(normalizedDecimalPrice) + .first + let amount = amountString.flatMap(Double.init) + let currency = fallbackCurrency ?? currencyCode(fromDisplayPrice: displayPrice) + return (amount, currency) + } + + private static func normalizedDecimalPrice(_ value: String) -> String? { + let raw = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.contains(where: \.isNumber) else { return nil } + + let separators = raw.enumerated().filter { $0.element == "." || $0.element == "," } + guard let lastSeparator = separators.last else { + return raw.filter(\.isNumber) + } + + let decimalSeparator = lastSeparator.element + let separatorIndex = raw.index(raw.startIndex, offsetBy: lastSeparator.offset) + let fractionalDigitCount = raw[raw.index(after: separatorIndex)...] + .filter(\.isNumber) + .count + let treatsLastSeparatorAsDecimal = fractionalDigitCount == 2 + || (separators.count == 1 && fractionalDigitCount > 0 && fractionalDigitCount < 3) + + var normalized = "" + for (offset, character) in raw.enumerated() { + if character.isNumber { + normalized.append(character) + } else if treatsLastSeparatorAsDecimal && offset == lastSeparator.offset && character == decimalSeparator { + normalized.append(".") + } + } + return normalized } - func getLocalizationResearchContext( - appStoreID: Int64, - storefronts: [String]? = nil, - platform: String? = nil, - competitorLimit: Int? = nil, - includeTargetApp: Bool = true, - refreshMissingMetadata: Bool = true, - destinationDirectoryPath: String? = nil - ) async throws -> OpenASOMCPLocalizationResearchContext { - let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) - let requestedStorefronts = try OpenASOMCPValidation.storefronts(storefronts) - let storefronts = - requestedStorefronts.isEmpty ? Self.defaultLocalizationStorefronts : requestedStorefronts - let analysisStorefronts = Self.storefrontsIncludingLocalizationBaseline(storefronts) - let platform = try OpenASOMCPValidation.platform(platform) - let competitorLimit = OpenASOMCPValidation.cappedLimit( - competitorLimit, - default: ResponseLimits.defaultLocalizationCompetitorLimit, - maximum: ResponseLimits.maximumLocalizationCompetitorLimit - ) + private static func currencyCode(fromDisplayPrice displayPrice: String) -> String? { + let value = displayPrice.trimmingCharacters(in: .whitespacesAndNewlines) + if value.contains("R$") { return "BRL" } + if value.contains("₩") { return "KRW" } + if value.contains("₹") { return "INR" } + if value.contains("€") { return "EUR" } + if value.contains("£") { return "GBP" } + if value.contains("¥") { return "JPY" } + if value.contains("$") { return "USD" } + return nil + } - let competitors = try await listCompetitors( - appStoreID: appStoreID, - storefronts: analysisStorefronts, - platform: platform.rawValue, - limit: competitorLimit, - evidenceLimit: ResponseLimits.overviewCompetitorEvidence - ) + private static func usdValue( + amount: Double?, + currency: String?, + exchangeRatesPerUSD: [String: Double] + ) -> Double? { + guard let amount, let currency else { return nil } + if currency == "USD" { return amount } + guard let rate = exchangeRatesPerUSD[currency], rate > 0 else { return nil } + return amount / rate + } - var subjects: [(role: String, appStoreID: Int64)] = [] - if includeTargetApp { - subjects.append(("target", appStoreID)) + private static func inferredBillingCadence(name: String) -> String? { + let normalized = name.lowercased() + if normalized.contains("annual") || normalized.contains("year") || normalized.contains("yr") { + return "yearly" } - for competitor in competitors { - guard let competitorID = Int64(competitor.appStoreID), - !subjects.contains(where: { $0.appStoreID == competitorID }) - else { - continue - } - subjects.append(("competitor", competitorID)) + if normalized.contains("quarter") { + return "quarterly" + } + if normalized.contains("month") || normalized.contains("monthly") { + return "monthly" } + if normalized.contains("week") || normalized.contains("weekly") { + return "weekly" + } + return nil + } - var fetchErrors: [OpenASOMCPLocalizationFetchError] = [] - var summariesByAppID: [Int64: OpenASOMCPAppSummary] = [:] - for subject in subjects { - do { - summariesByAppID[subject.appStoreID] = try await appSummaryResolvingCatalog( - appStoreID: subject.appStoreID, - storefront: Self.localizationBaselineStorefront - ) - } catch { - fetchErrors.append( - OpenASOMCPLocalizationFetchError( - appStoreID: String(subject.appStoreID), - storefront: Self.localizationBaselineStorefront, - error: OpenASOMCPErrorDTO(OpenASOError.map(error)) - )) + private static func inferredBillingCadences( + for rows: [AppStorefrontParsedPlanRow] + ) -> [Int: String] { + var cadences: [Int: String] = [:] + for row in rows { + if let explicitBillingCadence = row.explicitBillingCadence { + cadences[row.index] = explicitBillingCadence } } - if refreshMissingMetadata { - for subject in subjects where summariesByAppID[subject.appStoreID] != nil { - fetchErrors.append( - contentsOf: try await refreshMissingLocalizationMetadata( - appStoreID: subject.appStoreID, - storefronts: analysisStorefronts - )) - } + let groupedRows = Dictionary(grouping: rows) { row in + normalizedPricingPlanName(row.row.name) } - var screenshotExportsByAppID: [Int64: OpenASOMCPScreenshotExportResult] = [:] - if let destinationDirectoryPath { - _ = try OpenASOMCPValidation.writableDirectory(destinationDirectoryPath, createIfNeeded: true) - for subject in subjects where summariesByAppID[subject.appStoreID] != nil { - screenshotExportsByAppID[subject.appStoreID] = try await exportScreenshots( - appStoreID: subject.appStoreID, - storefronts: analysisStorefronts, - platform: platform.rawValue, - destinationDirectoryPath: destinationDirectoryPath - ) + for group in groupedRows.values { + let unknownRows = group.filter { cadences[$0.index] == nil && $0.price != nil } + guard !unknownRows.isEmpty else { continue } + + let knownRows = group.filter { cadences[$0.index] != nil && $0.price != nil } + if let monthlyPrice = knownRows.first(where: { cadences[$0.index] == "monthly" })?.price { + for row in unknownRows { + guard let price = row.price else { continue } + if price > monthlyPrice { + cadences[row.index] = "yearly" + } else if price < monthlyPrice { + cadences[row.index] = "weekly" + } else { + cadences[row.index] = "monthly" + } + } + continue } - } - let finalSubjects = subjects - let finalSummariesByAppID = summariesByAppID - let finalScreenshotExportsByAppID = screenshotExportsByAppID - let appContexts = try await backgroundModelStore.read { modelContext in - let languageCodesByStorefront = try Self.languageCodesByStorefront(in: modelContext) - return try finalSubjects.compactMap { subject -> OpenASOMCPLocalizationAppContext? in - guard - let storeApp = try Self.fetchStoreApp(appStoreID: subject.appStoreID, in: modelContext), - let app = finalSummariesByAppID[subject.appStoreID] - else { - return nil + let uniquePrices = Array(Set(unknownRows.compactMap(\.price))).sorted(by: >) + guard !uniquePrices.isEmpty else { continue } + + for row in unknownRows { + guard let price = row.price else { continue } + if uniquePrices.count == 1 { + cadences[row.index] = "monthly" + } else if uniquePrices.count == 2 { + cadences[row.index] = price == uniquePrices[0] ? "yearly" : "monthly" + } else if price == uniquePrices.first { + cadences[row.index] = "yearly" + } else if price == uniquePrices.last { + cadences[row.index] = "weekly" + } else { + cadences[row.index] = "monthly" } - return Self.localizationAppContext( - role: subject.role, - app: app, - storeApp: storeApp, - storefronts: storefronts, - baselineStorefront: Self.localizationBaselineStorefront, - platform: platform, - languageCodesByStorefront: languageCodesByStorefront, - screenshotExport: finalScreenshotExportsByAppID[subject.appStoreID] - ) } } - return OpenASOMCPLocalizationResearchContext( - appStoreID: String(appStoreID), - generatedAt: now(), - baselineStorefront: Self.localizationBaselineStorefront, - storefronts: storefronts, - platform: platform.rawValue, - apps: appContexts, - notes: Self.localizationResearchNotes( - refreshMissingMetadata: refreshMissingMetadata, - destinationDirectoryPath: destinationDirectoryPath - ), - errors: fetchErrors - ) + return cadences } - private func requireRankingProvider() throws -> any SearchRankingProvider { - guard let rankingProvider else { - throw OpenASOError.providerUnavailable( - "Ranking search is not configured for this MCP server.") + private static func localizedPricingPlanKey(_ plan: OpenASOMCPAppStorefrontPlan) -> String { + let cadence = normalizedPricingCadence(plan.billingCadence) + if let cadence, !cadence.isEmpty { + return [ + plan.appStoreID, + "cadence", + cadence + ].joined(separator: "::") } - return rankingProvider + return [ + plan.appStoreID, + "name", + normalizedPricingPlanName(plan.name) + ].joined(separator: "::") } - static func withRankingSearchTimeout( - operation: @escaping @Sendable () async throws -> T - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - group.addTask { - try await Task.sleep(nanoseconds: ResponseLimits.rankingSearchTimeoutNanoseconds) - throw OpenASOError.providerUnavailable( - "Ranking search timed out after 5 seconds. Reduce the keyword/storefront batch size and retry." - ) - } + private static func storefrontAdjustmentKey(_ plan: OpenASOMCPAppStorefrontPlan) -> String { + [ + normalizedPricingStorefront(plan.storefront), + normalizedPricingCadence(plan.billingCadence) ?? "unknown" + ].joined(separator: "::") + } - guard let result = try await group.next() else { - throw OpenASOError.providerUnavailable("Ranking search did not return a result.") - } - group.cancelAll() - return result - } + private static func normalizedPricingCadence(_ cadence: String?) -> String? { + let normalized = cadence?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let normalized, !normalized.isEmpty else { return nil } + return normalized + } + + private static func normalizedPricingPlanName(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func isConsumablePricingRow(_ row: AppStorefrontPlanRow) -> Bool { + let tokens = normalizedPricingPlanName(row.name) + .split { !$0.isLetter && !$0.isNumber } + .map(String.init) + return tokens.contains("pearl") || tokens.contains("pearls") } + + private static func isInAppPurchasesSectionTitle(_ title: String?) -> Bool { + guard let title else { return false } + let normalized = title + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "\u{2011}", with: "-") + .replacingOccurrences(of: "\u{2013}", with: "-") + .replacingOccurrences(of: "\u{2014}", with: "-") + return localizedInAppPurchaseSectionTitles.contains(normalized) + } + + private static let localizedInAppPurchaseSectionTitles: Set = [ + "in-app purchases", + "in-app purchase", + "in-app-käufe", + "achats intégrés", + "compras dentro do app", + "compras dentro de la app", + "compras dentro de la aplicación", + "in-app aankopen", + "acquisti in-app", + "köp inuti app", + "アプリ内課金", + "앱 내 구입", + "앱 내 구매", + "内购项目", + "app内課金" + ] +} + +struct AppPricingFetchRequest: Sendable, Hashable { + let appStoreID: Int64 + let storefront: String +} + +private struct AppPricingLookupResponse: Decodable { + let results: [AppPricingLookupPayload] +} + +private struct AppPricingLookupPayload: Decodable { + let price: Double? + let formattedPrice: String? + let currency: String? +} + +private struct AppStorefrontPlanRow: Sendable { + let name: String + let displayPrice: String +} + +private struct AppStorefrontParsedPlanRow: Sendable { + let index: Int + let row: AppStorefrontPlanRow + let price: Double? + let currency: String? + let priceUSD: Double? + let explicitBillingCadence: String? +} + +private struct USDExchangeRateResponse: Decodable { + let rates: [String: Double] } extension OpenASOMCPService { @@ -2700,6 +3957,142 @@ extension OpenASOMCPService { ) } + fileprivate static func globalKeywordTemplates() -> [GlobalTrackedKeywordTemplate] { + let json = UserDefaults.standard.string(forKey: globalKeywordTemplatesKey) ?? "[]" + return GlobalTrackedKeywordTemplate.decodeList(from: json) + .sorted { + if $0.storefront == $1.storefront { + if $0.platformRaw == $1.platformRaw { + return $0.term.localizedStandardCompare($1.term) == .orderedAscending + } + return $0.platformRaw < $1.platformRaw + } + return $0.storefront < $1.storefront + } + } + + fileprivate static func setGlobalKeywordTemplates(_ templates: [GlobalTrackedKeywordTemplate]) { + UserDefaults.standard.set( + GlobalTrackedKeywordTemplate.encodeList(templates), + forKey: globalKeywordTemplatesKey + ) + } + + fileprivate static func globalKeywordTemplate(_ template: GlobalTrackedKeywordTemplate) + -> OpenASOMCPGlobalKeywordTemplate + { + OpenASOMCPGlobalKeywordTemplate( + id: template.id, + keyword: template.term, + storefront: template.storefront, + platform: template.platform.rawValue, + createdAt: template.createdAt + ) + } + + fileprivate static func deleteSnapshots(for track: TrackedAppKeyword, in modelContext: ModelContext) { + for snapshot in track.snapshots { + for result in snapshot.topResults { + modelContext.delete(result) + } + snapshot.topResults.removeAll() + modelContext.delete(snapshot) + } + track.snapshots.removeAll() + } + + fileprivate static func keywordRankingSnapshot( + _ snapshot: TrackedKeywordDailyRanking, + track: TrackedAppKeyword + ) -> OpenASOMCPKeywordRankingSnapshot { + OpenASOMCPKeywordRankingSnapshot( + id: snapshot.snapshotKey, + trackIdentityKey: track.identityKey, + appStoreID: String(track.appStoreID), + keyword: track.term, + queryKey: track.queryKey, + storefront: track.storefront, + platform: track.platform.rawValue, + rank: snapshot.rank, + searchedAt: snapshot.searchedAt, + source: snapshot.source.rawValue, + resultCount: snapshot.resultCount, + errorMessage: snapshot.errorMessage, + topResults: snapshot.sortedTopResults.map(trackedKeywordRankedResult) + ) + } + + fileprivate static func trackedKeywordRankedResult(_ result: TrackedKeywordRankedResult) + -> OpenASOMCPTrackedKeywordRankedResult + { + OpenASOMCPTrackedKeywordRankedResult( + position: result.position, + appStoreID: String(result.appStoreID), + bundleID: result.bundleID, + name: result.name, + subtitle: result.subtitle, + sellerName: result.sellerName + ) + } + + fileprivate static func keywordRankingCrawl(_ crawl: KeywordRankingCrawl) + -> OpenASOMCPKeywordRankingCrawl + { + OpenASOMCPKeywordRankingCrawl( + id: crawl.observationKey, + queryKey: crawl.queryKey, + keyword: crawl.keyword, + storefront: crawl.storefront, + platform: crawl.platform.rawValue, + observedAt: crawl.observedAt, + observedHour: crawl.observedHour, + source: crawl.source.rawValue, + resultCount: crawl.resultCount, + submissionCount: crawl.submissionCount, + winningCount: crawl.winningCount, + confidence: crawl.confidenceRaw, + items: crawl.sortedItems.map(keywordRankingItem) + ) + } + + fileprivate static func keywordRankingItem(_ item: KeywordAppRanking) -> OpenASOMCPRankedApp { + OpenASOMCPRankedApp( + id: String(item.appStoreID), + appStoreID: String(item.appStoreID), + position: item.position, + name: item.name, + subtitle: item.subtitle, + sellerName: item.sellerName, + bundleID: item.bundleID, + iconURLString: nil, + primaryGenreName: nil, + ratingCount: nil, + averageRating: nil, + screenshotURLs: [] + ) + } + + fileprivate static func ratingHistoryItem(_ rating: AppDailyRating) -> OpenASOMCPRatingHistoryItem { + OpenASOMCPRatingHistoryItem( + id: rating.identityKey, + appStoreID: String(rating.appStoreID), + storefront: rating.storefront, + ratingDate: rating.ratingDate, + ratingCount: rating.ratingCount, + averageRating: rating.averageRating, + oneStarRatingCount: rating.oneStarRatingCount, + twoStarRatingCount: rating.twoStarRatingCount, + threeStarRatingCount: rating.threeStarRatingCount, + fourStarRatingCount: rating.fourStarRatingCount, + fiveStarRatingCount: rating.fiveStarRatingCount, + observedAt: rating.observedAt, + submissionCount: rating.submissionCount, + winningCount: rating.winningCount, + confidence: rating.confidenceRaw, + source: rating.source.rawValue + ) + } + fileprivate static func keywordScore(_ keyword: OpenASOMCPKeywordSummary) -> OpenASOMCPKeywordScore { @@ -3275,6 +4668,30 @@ extension OpenASOMCPService { } } + fileprivate static func trackedQueryKeys( + appStoreID: Int64, + storefronts: Set, + platform: AppPlatform?, + keyword: String?, + queryKey: String?, + in modelContext: ModelContext + ) throws -> [String] { + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.appStoreID == appStoreID + } + ) + return Array(Set(try modelContext.fetch(descriptor).compactMap { track in + if !storefronts.isEmpty && !storefronts.contains(track.storefront) { return nil } + if let platform, track.platform != platform { return nil } + if let keyword, track.term.localizedCaseInsensitiveCompare(keyword) != .orderedSame { + return nil + } + if let queryKey, track.queryKey != queryKey { return nil } + return track.queryKey + })) + } + fileprivate static func deriveCompetitors( appStoreID: Int64, storefronts: [String], diff --git a/OpenASO/Services/Persistence/ModelContainerFactory.swift b/OpenASO/Services/Persistence/ModelContainerFactory.swift index 450dd2e..806b007 100644 --- a/OpenASO/Services/Persistence/ModelContainerFactory.swift +++ b/OpenASO/Services/Persistence/ModelContainerFactory.swift @@ -3,7 +3,7 @@ import SwiftData enum ModelContainerFactory { static var schema: Schema { - Schema(OpenASOSchemaV1.models) + Schema(OpenASOSchemaV2.models) } static func makeModelContainer( diff --git a/OpenASO/Services/RatingsReviews/AppStorefrontRatingService.swift b/OpenASO/Services/RatingsReviews/AppStorefrontRatingService.swift index fe3f887..ea8c018 100644 --- a/OpenASO/Services/RatingsReviews/AppStorefrontRatingService.swift +++ b/OpenASO/Services/RatingsReviews/AppStorefrontRatingService.swift @@ -3,6 +3,8 @@ import OSLog import SwiftData final class AppStorefrontRatingService: Sendable { + private static let ratingFetchConcurrency = 6 + private let fetcher: AppStorefrontRatingFetcher private let retryPolicy: AppStorefrontRatingRetryPolicy private let retrySleeper: @Sendable (UInt64) async throws -> Void @@ -67,61 +69,97 @@ final class AppStorefrontRatingService: Sendable { var completedCount = 0 var failureCount = 0 await progress?(0, targetStorefronts.count, 0) - for storefront in targetStorefronts { - do { - OpenASOLog.ratings.debug( - "Refreshing ratings storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public)" - ) - let result = try await fetcher.fetchRatings( - appStoreID: appStoreID, - storefront: storefront, - retryPolicy: retryPolicy, - retrySleeper: retrySleeper - ) - OpenASOLog.ratings.info( - "Fetched ratings storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) ratingCount=\(result.ratingCount.map(String.init) ?? "nil", privacy: .public) averageRating=\(result.averageRating.map { String(format: "%.2f", $0) } ?? "nil", privacy: .public)" - ) - outcomes.append(AppStorefrontRatingRefreshOutcome(storefront: storefront, result: result, error: nil)) - } catch let unavailable as AppStorefrontRatingStorefrontUnavailable { - OpenASOLog.ratings.info( - "Ratings unavailable in storefront storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) reason=\(unavailable.localizedDescription, privacy: .public)" - ) - outcomes.append(AppStorefrontRatingRefreshOutcome( - storefront: storefront, - result: nil, - error: nil, - unavailabilityReason: unavailable.localizedDescription, - clearsStoredRatings: true - )) - } catch let mismatch as AppStorefrontRatingStorefrontMismatch { - let mappedError = OpenASOError.providerUnavailable(mismatch.localizedDescription) - OpenASOLog.ratings.error( - "Ratings storefront mismatch storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) actualStorefront=\(mismatch.actual, privacy: .public) finalURL=\(mismatch.finalURL ?? "nil", privacy: .public)" - ) - outcomes.append(AppStorefrontRatingRefreshOutcome( - storefront: storefront, - result: nil, - error: mappedError, - clearsStoredRatings: true - )) - failureCount += 1 - } catch { - let mappedError = OpenASOError.map(error) - OpenASOLog.ratings.error( - "Ratings refresh failed storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) error=\(mappedError.localizedDescription, privacy: .public)" - ) - outcomes.append(AppStorefrontRatingRefreshOutcome( - storefront: storefront, - result: nil, - error: mappedError - )) - failureCount += 1 + + await withTaskGroup(of: AppStorefrontRatingRefreshOutcome.self) { group in + var nextStorefrontIndex = 0 + var activeFetchCount = 0 + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < Self.ratingFetchConcurrency, + nextStorefrontIndex < targetStorefronts.count else { + return + } + + let storefront = targetStorefronts[nextStorefrontIndex] + nextStorefrontIndex += 1 + activeFetchCount += 1 + group.addTask { + await self.fetchRatingOutcome(appStoreID: appStoreID, storefront: storefront) + } + } + + for _ in 0.. AppStorefrontRatingRefreshOutcome { + do { + OpenASOLog.ratings.debug( + "Refreshing ratings storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public)" + ) + let result = try await fetcher.fetchRatings( + appStoreID: appStoreID, + storefront: storefront, + retryPolicy: retryPolicy, + retrySleeper: retrySleeper + ) + OpenASOLog.ratings.info( + "Fetched ratings storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) ratingCount=\(result.ratingCount.map(String.init) ?? "nil", privacy: .public) averageRating=\(result.averageRating.map { String(format: "%.2f", $0) } ?? "nil", privacy: .public)" + ) + return AppStorefrontRatingRefreshOutcome(storefront: storefront, result: result, error: nil) + } catch let unavailable as AppStorefrontRatingStorefrontUnavailable { + OpenASOLog.ratings.info( + "Ratings unavailable in storefront storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) reason=\(unavailable.localizedDescription, privacy: .public)" + ) + return AppStorefrontRatingRefreshOutcome( + storefront: storefront, + result: nil, + error: nil, + unavailabilityReason: unavailable.localizedDescription, + clearsStoredRatings: true + ) + } catch let mismatch as AppStorefrontRatingStorefrontMismatch { + let mappedError = OpenASOError.providerUnavailable(mismatch.localizedDescription) + OpenASOLog.ratings.error( + "Ratings storefront mismatch storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) actualStorefront=\(mismatch.actual, privacy: .public) finalURL=\(mismatch.finalURL ?? "nil", privacy: .public)" + ) + return AppStorefrontRatingRefreshOutcome( + storefront: storefront, + result: nil, + error: mappedError, + clearsStoredRatings: true + ) + } catch { + let mappedError = OpenASOError.map(error) + OpenASOLog.ratings.error( + "Ratings refresh failed storefront=\(storefront, privacy: .public) appStoreID=\(appStoreID, privacy: .public) error=\(mappedError.localizedDescription, privacy: .public)" + ) + return AppStorefrontRatingRefreshOutcome( + storefront: storefront, + result: nil, + error: mappedError + ) + } } func persist( diff --git a/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift b/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift index 9900a9b..cd73a0f 100644 --- a/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift +++ b/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift @@ -77,6 +77,9 @@ struct RankingStatsRebuildRequest: Hashable, Sendable { } final class RankingRefreshCoordinator: Sendable { + private static let rankingFetchConcurrency = 4 + private static let rankingPersistenceBatchSize = 5 + private let rankingProvider: any SearchRankingProvider private let appCatalogService: AppCatalogService private let analyticsService: AnalyticsService @@ -378,6 +381,12 @@ final class RankingRefreshCoordinator: Sendable { } pruneRankedResults(for: snapshot, keeping: page.items.map(\.appStoreID), in: modelContext) pruneObservationItems(for: observation, keeping: page.items.map(\.appStoreID), in: modelContext) + upsertKeywordDifficulty( + for: track, + page: page, + searchedAt: snapshot.searchedAt, + in: modelContext + ) if rebuildDerivedStats { self.rebuildDerivedStats(for: [RankingStatsRebuildRequest(track: track)], in: modelContext) @@ -523,6 +532,44 @@ final class RankingRefreshCoordinator: Sendable { } } + private func upsertKeywordDifficulty( + for track: TrackedAppKeyword, + page: SearchRankingPage, + searchedAt: Date, + in modelContext: ModelContext + ) { + let result = KeywordDifficultyEstimator.estimate( + keyword: track.term, + resultCount: page.resultCount, + topResults: page.items + ) + let metrics: KeywordDailyMetric + if let existing = try? fetchKeywordMetrics(queryKey: track.queryKey, in: modelContext) { + metrics = existing + } else { + metrics = KeywordDailyMetric( + queryKey: track.queryKey, + keyword: track.term, + storefront: track.storefront, + platform: track.platform, + popularityScore: nil, + difficultyScore: result.score, + source: .rankingDifficulty + ) + modelContext.insert(metrics) + } + + metrics.keyword = track.term + metrics.storefront = track.storefront + metrics.platform = track.platform + metrics.difficultyScore = result.score + metrics.updatedAt = searchedAt + metrics.notes = result.notes + if metrics.popularityScore == nil { + metrics.source = .rankingDifficulty + } + } + private func pruneRankedResults( for snapshot: TrackedKeywordDailyRanking, keeping appStoreIDs: [Int64], @@ -820,44 +867,114 @@ final class RankingRefreshCoordinator: Sendable { var outcomes: [RefreshOutcome] = [] var statsRebuildRequests = Set() + var pendingPageResults: [RankingRefreshPageResult] = [] var completedCount = 0 var failureCount = 0 if !tracks.isEmpty { await progress?(0, tracks.count, 0) } - for track in tracks { - let result = await refresh( - track: track, - in: modelContext, - limit: limit, - recordsTrigger: false, - rebuildDerivedStats: false - ) - switch result { - case .success(let snapshot): - statsRebuildRequests.insert(RankingStatsRebuildRequest(track: track)) - outcomes.append(RefreshOutcome( - trackID: track.persistentModelID, - snapshotID: snapshot.persistentModelID, - rank: snapshot.rank, - searchedAt: snapshot.searchedAt, - error: nil - )) - case .failure(let error): - track.statusMessage = "Ranking failed to refresh. \(error.localizedDescription)" - try? modelContext.save() - outcomes.append(RefreshOutcome( - trackID: track.persistentModelID, - snapshotID: nil, - rank: nil, - searchedAt: nil, - error: error - )) - failureCount += 1 + + func flushPendingPageResults() { + guard !pendingPageResults.isEmpty else { return } + + let pageResults = pendingPageResults + pendingPageResults.removeAll(keepingCapacity: true) + for pageResult in pageResults { + do { + let snapshot = try persistRankingPage( + pageResult, + in: modelContext, + rebuildDerivedStats: false, + saveChanges: false + ) + statsRebuildRequests.insert(RankingStatsRebuildRequest(track: snapshot.keywordTrack)) + outcomes.append(RefreshOutcome( + trackID: snapshot.keywordTrack.persistentModelID, + snapshotID: snapshot.persistentModelID, + rank: snapshot.rank, + searchedAt: snapshot.searchedAt, + error: nil + )) + } catch { + let mappedError = OpenASOError.map(error) + _ = try? recordRefreshFailure( + identityKey: pageResult.request.identityKey, + error: mappedError, + in: modelContext, + saveChanges: false + ) + outcomes.append(RefreshOutcome( + trackID: tracks.first { $0.identityKey == pageResult.request.identityKey }?.persistentModelID ?? tracks[0].persistentModelID, + snapshotID: nil, + rank: nil, + searchedAt: nil, + error: mappedError + )) + failureCount += 1 + } + } + try? modelContext.save() + } + + let rankingRequests = tracks.map(RankingRefreshRequest.init) + let rankingPageFetcher = makeRankingPageFetcher(limit: limit) + await withTaskGroup(of: (RankingRefreshRequest, Result).self) { group in + var nextRequestIndex = 0 + var activeFetchCount = 0 + + func enqueueNextFetchIfPossible() { + guard activeFetchCount < Self.rankingFetchConcurrency, + nextRequestIndex < rankingRequests.count + else { + return + } + + let rankingRequest = rankingRequests[nextRequestIndex] + nextRequestIndex += 1 + activeFetchCount += 1 + group.addTask { + let result = await rankingPageFetcher(rankingRequest) + return (rankingRequest, result) + } + } + + for _ in 0..= Self.rankingPersistenceBatchSize { + flushPendingPageResults() + } + case .failure(let error): + _ = try? recordRefreshFailure( + identityKey: rankingRequest.identityKey, + error: error, + in: modelContext, + saveChanges: false + ) + outcomes.append(RefreshOutcome( + trackID: tracks.first { $0.identityKey == rankingRequest.identityKey }?.persistentModelID ?? tracks[0].persistentModelID, + snapshotID: nil, + rank: nil, + searchedAt: nil, + error: error + )) + failureCount += 1 + } + + completedCount += 1 + await progress?(completedCount, tracks.count, failureCount) + enqueueNextFetchIfPossible() } - completedCount += 1 - await progress?(completedCount, tracks.count, failureCount) } + + flushPendingPageResults() if !statsRebuildRequests.isEmpty { rebuildDerivedStats(for: statsRebuildRequests, in: modelContext) try? modelContext.save() @@ -1121,6 +1238,141 @@ final class RankingRefreshCoordinator: Sendable { } } +private enum KeywordDifficultyEstimator { + static func estimate( + keyword: String, + resultCount: Int, + topResults: [SearchRankingItem] + ) -> KeywordDifficultyEstimate { + let keywordTokens = tokens(keyword) + guard !keywordTokens.isEmpty else { + return KeywordDifficultyEstimate(score: nil, notes: "Difficulty unavailable: empty keyword.") + } + + let rankedResults = topResults + .sorted { $0.position < $1.position } + .prefix(10) + guard !rankedResults.isEmpty else { + return KeywordDifficultyEstimate(score: nil, notes: "Difficulty unavailable: no ranking results.") + } + + let weightedResults = rankedResults.map { item -> (weight: Double, score: Double) in + let positionWeight = 1 / sqrt(Double(max(item.position, 1))) + return ( + positionWeight, + appCompetitionScore(item: item, keywordTokens: keywordTokens) + ) + } + let totalWeight = weightedResults.reduce(0) { $0 + $1.weight } + let weightedCompetition = weightedResults.reduce(0) { $0 + ($1.score * $1.weight) } / max(totalWeight, 1) + let titleSaturation = saturationScore( + topResults: Array(rankedResults), + keywordTokens: keywordTokens, + text: { [$0.name, $0.subtitle].compactMap(\.self).joined(separator: " ") } + ) + let phrase = keywordTokens.joined(separator: " ") + let exactTitlePressure = rankedResults.contains { + normalizedText($0.name).contains(phrase) + } ? 10.0 : 0.0 + let depthPressure = min(12, log10(Double(max(resultCount, rankedResults.count)) + 1) * 8) + + let score = Int( + min(100, max(0, round(weightedCompetition * 0.66 + titleSaturation * 0.16 + exactTitlePressure + depthPressure))) + ) + let notes = [ + "Difficulty is derived from latest top App Store ranking results.", + "Signals: top-10 app rating volume/quality, title/subtitle keyword match, result depth, and exact-title pressure.", + "Score \(score): \(label(for: score))." + ].joined(separator: " ") + return KeywordDifficultyEstimate(score: score, notes: notes) + } + + private static func appCompetitionScore( + item: SearchRankingItem, + keywordTokens: [String] + ) -> Double { + let ratingVolume = min(1, log10(Double(max(item.ratingCount ?? 0, 0)) + 1) / 5) + let ratingQuality = min(1, max(0, ((item.averageRating ?? 3.5) - 3) / 2)) + let metadataMatch = metadataMatchScore(item: item, keywordTokens: keywordTokens) + let rankAuthority = 1 / sqrt(Double(max(item.position, 1))) + + return min( + 100, + ratingVolume * 42 + + ratingQuality * 14 + + metadataMatch * 34 + + rankAuthority * 10 + ) + } + + private static func metadataMatchScore( + item: SearchRankingItem, + keywordTokens: [String] + ) -> Double { + let phrase = keywordTokens.joined(separator: " ") + let title = normalizedText(item.name) + let subtitle = normalizedText(item.subtitle ?? "") + let combined = [title, subtitle].filter { !$0.isEmpty }.joined(separator: " ") + + if title.contains(phrase) { return 1 } + if subtitle.contains(phrase) { return 0.8 } + + let titleTokens = Set(tokens(title)) + let combinedTokens = Set(tokens(combined)) + let titleCoverage = tokenCoverage(keywordTokens, in: titleTokens) + let combinedCoverage = tokenCoverage(keywordTokens, in: combinedTokens) + return max(titleCoverage * 0.75, combinedCoverage * 0.55) + } + + private static func saturationScore( + topResults: [SearchRankingItem], + keywordTokens: [String], + text: (SearchRankingItem) -> String + ) -> Double { + guard !topResults.isEmpty else { return 0 } + let matches = topResults.filter { item in + tokenCoverage(keywordTokens, in: Set(tokens(text(item)))) >= 0.75 + }.count + return (Double(matches) / Double(topResults.count)) * 100 + } + + private static func tokenCoverage(_ keywordTokens: [String], in candidateTokens: Set) -> Double { + guard !keywordTokens.isEmpty else { return 0 } + let matchCount = keywordTokens.filter { candidateTokens.contains($0) }.count + return Double(matchCount) / Double(keywordTokens.count) + } + + private static func tokens(_ value: String) -> [String] { + normalizedText(value) + .split { !$0.isLetter && !$0.isNumber } + .map(String.init) + .filter { !$0.isEmpty } + } + + private static func normalizedText(_ value: String) -> String { + value + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func label(for score: Int) -> String { + switch score { + case 0..<30: + return "low competition" + case 30..<60: + return "moderate competition" + default: + return "high competition" + } + } +} + +private struct KeywordDifficultyEstimate { + let score: Int? + let notes: String +} + struct RefreshOutcome { let trackID: PersistentIdentifier let snapshotID: PersistentIdentifier? diff --git a/OpenASO/Services/Storefront/AppStoreWebMetadataProvider.swift b/OpenASO/Services/Storefront/AppStoreWebMetadataProvider.swift index cd0c71f..9f1b0df 100644 --- a/OpenASO/Services/Storefront/AppStoreWebMetadataProvider.swift +++ b/OpenASO/Services/Storefront/AppStoreWebMetadataProvider.swift @@ -95,7 +95,7 @@ final class AppStoreWebMetadataProvider: Sendable { ) } - private static func serializedServerData(from html: String) -> String? { + static func serializedServerData(from html: String) -> String? { let pattern = #"]*id=["']serialized-server-data["'][^>]*>(.*?)"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return nil @@ -334,7 +334,7 @@ final class AppStoreWebMetadataProvider: Sendable { return normalized.isEmpty ? "us" : normalized } - private static func htmlDecoded(_ value: String) -> String { + static func htmlDecoded(_ value: String) -> String { value .replacingOccurrences(of: """, with: "\"") .replacingOccurrences(of: """, with: "\"") diff --git a/OpenASO/Services/Storefront/StorefrontCatalog.swift b/OpenASO/Services/Storefront/StorefrontCatalog.swift index 1bbcda1..8c36d6f 100644 --- a/OpenASO/Services/Storefront/StorefrontCatalog.swift +++ b/OpenASO/Services/Storefront/StorefrontCatalog.swift @@ -15,6 +15,13 @@ final class StorefrontCatalog { return storefronts } + nonisolated static func bundledStorefrontCodes() throws -> [String] { + let codes = try loadBundledStorefronts() + .map { $0.code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + return Array(Set(codes)).sorted() + } + func seedIfNeeded(in modelContext: ModelContext) throws { let seeds = try bundledStorefronts() try Self.seedIfNeeded(seeds: seeds, in: modelContext) From 352e9beceec64440e5fb2e7dcc474aea82709b1d Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 00:54:48 +0530 Subject: [PATCH 2/8] perf: Parallelize refresh pipelines and speed up keyword management - Fetch storefront reviews with a 6-wide task group instead of a serial per-country loop - Run keywords and ratings/reviews refreshes concurrently; overlap ratings and reviews fetch legs - Keep the ranking fetch window saturated during persistence flushes - Dedup daily stale-track refreshes by query key so shared keywords are fetched once - Batch keyword popularity persistence into one write per storefront batch; raise storefront concurrency to 4; retry transient rate-limit/network errors with backoff; fetch >100-term chunks in parallel - Debounce keyword search and split heavy row materialization from in-memory filters in AppKeywordsView - Cache reusable-keyword JSON, hoist count grouping out of the sort comparator, and batch KeywordQuery resolution in AddKeywordsSheet - Move ranking-list sheet enrichment joins into a single background read - Merge redundant background reads in MCP refreshKeywordMetrics - Update tests for schema V2 migration plan and concurrent chunk ordering Co-Authored-By: Claude Fable 5 --- .../AppDetail/Keywords/AddKeywordsSheet.swift | 113 +++++--- .../AppDetail/Keywords/AppKeywordsView.swift | 112 ++++---- .../Table/KeywordRankingListSheet.swift | 156 ++++++++--- .../Table/KeywordRankingResultRow.swift | 2 +- .../AppDetail/AppDetailRefreshService.swift | 259 +++++++++++------- .../AppleAds/AppleAdsWebSession.swift | 56 +++- .../AppleAds/KeywordMetricsService.swift | 223 ++++++++------- OpenASO/Services/MCP/OpenASOMCPService.swift | 32 ++- .../RankingRefreshCoordinator.swift | 86 ++++-- OpenASOTests/AppServicesDependencyTests.swift | 10 +- .../RankingRefreshCoordinatorTests.swift | 4 +- 11 files changed, 680 insertions(+), 373 deletions(-) diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index 9e91a23..b293a93 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -25,6 +25,9 @@ struct AddKeywordsSheet: View { @State private var errorMessage: String? @State private var statusMessage: String? @State private var isSubmitting = false + // Cache of the decoded reusable-keyword list; kept in sync with + // globalKeywordTemplatesJSON so the JSON isn't parsed on every render. + @State private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] = [] init( trackedApp: TrackedApp, @@ -136,6 +139,12 @@ struct AddKeywordsSheet: View { } .padding(24) .frame(minWidth: 780, minHeight: 780) + .onAppear { + globalKeywordTemplates = GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + } + .onChange(of: globalKeywordTemplatesJSON) { _, newValue in + globalKeywordTemplates = GlobalTrackedKeywordTemplate.decodeList(from: newValue) + } } @ViewBuilder @@ -189,9 +198,16 @@ struct AddKeywordsSheet: View { || storefront.code.localizedCaseInsensitiveContains(normalizedSearch) } + // Build the count grouping once per render pass instead of once per + // sort comparison. + let counts = keywordCountsByStorefront + func count(for code: String) -> Int { + counts[code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), default: 0] + } + return matchingStorefronts.sorted { lhs, rhs in - let lhsCount = keywordCount(for: lhs.code) - let rhsCount = keywordCount(for: rhs.code) + let lhsCount = count(for: lhs.code) + let rhsCount = count(for: rhs.code) if lhsCount == rhsCount { return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } @@ -206,10 +222,6 @@ struct AddKeywordsSheet: View { .mapValues(\.count) } - private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { - GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) - } - private var reusableKeywordsForSelection: [GlobalTrackedKeywordTemplate] { globalKeywordTemplates .filter { template in @@ -331,18 +343,54 @@ struct AddKeywordsSheet: View { errorMessage = nil statusMessage = nil - let existingKeys: Set - do { - existingKeys = try existingDuplicateKeys() - } catch { - errorMessage = OpenASOError.map(error).localizedDescription - isSubmitting = false - return - } - var mutableExistingKeys = existingKeys + // The @Query already holds this app's tracks with the same predicate; + // no need to re-fetch them. + var mutableExistingKeys = Set( + trackedKeywords.map { + duplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) + } + ) var insertedCount = 0 var insertedTracks: [TrackedAppKeyword] = [] + // Resolve every needed keyword query with one batch fetch instead of + // one fetch per candidate. + let neededQueryKeys = Array(Set( + candidates + .filter { candidate in + !mutableExistingKeys.contains(duplicateKey( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform + )) + } + .map { candidate in + KeywordQuery.makeQueryKey( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform + ) + } + )) + var queriesByKey: [String: KeywordQuery] = [:] + if !neededQueryKeys.isEmpty { + do { + let targetQueryKeys = neededQueryKeys + let descriptor = FetchDescriptor( + predicate: #Predicate { query in + targetQueryKeys.contains(query.queryKey) + } + ) + for query in try modelContext.fetch(descriptor) { + queriesByKey[query.queryKey] = query + } + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + isSubmitting = false + return + } + } + for candidate in candidates { let identityKey = duplicateKey( term: candidate.keyword, storefront: candidate.storefrontCode, @@ -350,18 +398,22 @@ struct AddKeywordsSheet: View { guard !mutableExistingKeys.contains(identityKey) else { continue } + let queryKey = KeywordQuery.makeQueryKey( + term: candidate.keyword, + storefront: candidate.storefrontCode, + platform: candidate.platform + ) let query: KeywordQuery - do { - query = try KeywordQuery.fetchOrInsert( + if let existing = queriesByKey[queryKey] { + query = existing + } else { + query = KeywordQuery( term: candidate.keyword, storefront: candidate.storefrontCode, - platform: candidate.platform, - in: modelContext + platform: candidate.platform ) - } catch { - errorMessage = OpenASOError.map(error).localizedDescription - isSubmitting = false - return + modelContext.insert(query) + queriesByKey[queryKey] = query } let track = TrackedAppKeyword( @@ -445,21 +497,6 @@ struct AddKeywordsSheet: View { ].joined(separator: "::") } - private func existingDuplicateKeys() throws -> Set { - let appStoreID = trackedApp.appStoreID - let descriptor = FetchDescriptor( - predicate: #Predicate { track in - track.appStoreID == appStoreID - } - ) - return Set( - try modelContext.fetch(descriptor) - .map { - duplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) - } - ) - } - private var parsedKeywords: [String] { let separators = CharacterSet(charactersIn: ",\n") let parts = diff --git a/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift b/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift index 9b2308a..5a97c44 100644 --- a/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift +++ b/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift @@ -22,8 +22,9 @@ struct AppKeywordsView: View { @State private var metricsByQueryKey: [String: KeywordMetricsSnapshot] = [:] @State private var insightsDataset = KeywordInsightsDataset(appStoreID: 0, series: [], source: .local) + @State private var materializedRows: [KeywordWorkspaceRow] = [] @State private var keywordRows: [KeywordWorkspaceRow] = [] - @State private var derivedRowsSignature: String? + @State private var derivedRowsSignature: Int? init( trackedApp: TrackedApp, @@ -78,37 +79,45 @@ struct AppKeywordsView: View { } } - private var rowReloadSignature: String { - [ - String(refreshToken), - String(services.backgroundModelStoreRevision), - String(trackedApp.appStoreID), - selectedPlatformFilter.id, - tracksSignature, - selectedDateRange.id, - searchText.trimmingCharacters(in: .whitespacesAndNewlines), - popularityFilterRange.description, - difficultyFilterRange.description, - positionFilterRange.description, - changeFilterRange.description, - String(showsOnlyChangedKeywords) - ].joined(separator: "::") - } - - private var tracksSignature: String { - tracks.map { track in - [ - track.identityKey, - String(track.lastRefreshAt?.timeIntervalSinceReferenceDate ?? 0), - String(track.rankingAppCount ?? -1), - track.statusMessage ?? "" - ].joined(separator: "|") + // Inputs that require re-running the expensive SwiftData row pipeline. + // Search text and the slider filters are intentionally excluded — they are + // applied in memory to the materialized rows. + private var rowMaterializationSignature: Int { + var hasher = Hasher() + hasher.combine(refreshToken) + hasher.combine(services.backgroundModelStoreRevision) + hasher.combine(trackedApp.appStoreID) + hasher.combine(selectedStorefrontFilter.id) + hasher.combine(selectedPlatformFilter.id) + hasher.combine(selectedDateRange.id) + hashTracks(into: &hasher) + return hasher.finalize() + } + + private var rowFilterSignature: Int { + var hasher = Hasher() + hasher.combine(rowMaterializationSignature) + hasher.combine(searchText.trimmingCharacters(in: .whitespacesAndNewlines)) + hasher.combine(popularityFilterRange) + hasher.combine(difficultyFilterRange) + hasher.combine(positionFilterRange) + hasher.combine(changeFilterRange) + hasher.combine(showsOnlyChangedKeywords) + return hasher.finalize() + } + + private func hashTracks(into hasher: inout Hasher) { + hasher.combine(tracks.count) + for track in tracks { + hasher.combine(track.identityKey) + hasher.combine(track.lastRefreshAt) + hasher.combine(track.rankingAppCount) + hasher.combine(track.statusMessage) } - .joined(separator: "::") } private var isLoadingKeywordRows: Bool { - derivedRowsSignature != rowReloadSignature + derivedRowsSignature != rowMaterializationSignature } private func insightsSignature(for rows: [KeywordWorkspaceRow]) -> String { @@ -147,7 +156,7 @@ struct AppKeywordsView: View { private static let rankingFetchChunkSize = 500 - private func makeFilteredRows( + private func makeRows( from tracks: [TrackedAppKeyword], snapshotBuckets: SnapshotBuckets ) -> [KeywordWorkspaceRow] { @@ -168,10 +177,6 @@ struct AppKeywordsView: View { } return rows - .filter(matchesPosition) - .filter(matchesChange) - .filter(matchesChangedOnly) - .sorted(by: rowSort) } var body: some View { @@ -202,34 +207,49 @@ struct AppKeywordsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .task(id: rowReloadSignature) { - await updateKeywordRows() + .task(id: rowMaterializationSignature) { + await materializeKeywordRows() + } + .task(id: rowFilterSignature) { + // Debounce keystroke and slider churn; only the cheap in-memory + // filters run here. + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + applyRowFilters() } .task(id: insightsSignature(for: rows)) { await reloadInsights(visibleTracks: rows.map(\.track)) } } - private func updateKeywordRows() async { - let signature = rowReloadSignature + private func materializeKeywordRows() async { + let signature = rowMaterializationSignature do { - let searchFilteredTracks = tracks - .filter(matchesPlatform) - .filter(matchesSearch) - let loadedMetrics = try await loadMetricsSnapshots(for: searchFilteredTracks.map(\.queryKey)) + let platformTracks = tracks.filter(matchesPlatform) + let loadedMetrics = try await loadMetricsSnapshots(for: platformTracks.map(\.queryKey)) metricsByQueryKey = loadedMetrics - let filteredTracks = searchFilteredTracks.filter(matchesMetrics) - let snapshotBuckets = try fetchSnapshotBuckets(for: filteredTracks) - keywordRows = makeFilteredRows( - from: filteredTracks, + let snapshotBuckets = try fetchSnapshotBuckets(for: platformTracks) + materializedRows = makeRows( + from: platformTracks, snapshotBuckets: snapshotBuckets ) } catch { metricsByQueryKey = [:] - keywordRows = [] + materializedRows = [] reportError(OpenASOError.map(error).localizedDescription) } derivedRowsSignature = signature + applyRowFilters() + } + + private func applyRowFilters() { + keywordRows = materializedRows + .filter { matchesSearch(for: $0.track) } + .filter { matchesMetrics(for: $0.track) } + .filter(matchesPosition) + .filter(matchesChange) + .filter(matchesChangedOnly) + .sorted(by: rowSort) } private func loadMetricsSnapshots(for queryKeys: [String]) async throws -> [String: KeywordMetricsSnapshot] { diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift index a552bec..f1ade32 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingListSheet.swift @@ -156,18 +156,80 @@ struct KeywordRankingListSheet: View { ) } .task(id: crawlKey) { - loadRankingItems() - loadEnrichedRows(includeScreenshots: isShowingScreenshots) + await reloadFromStore(includeScreenshots: isShowingScreenshots) } .task(id: isShowingScreenshots) { guard isShowingScreenshots, !enrichedRowsIncludeScreenshots else { return } - loadEnrichedRows(includeScreenshots: true) + await reloadFromStore(includeScreenshots: true) } .frame(minWidth: 1_260, idealWidth: 1_420, minHeight: 720, idealHeight: 920) } - private func loadRankingItems() { - guard let crawlKey else { return } + // Runs the ranking-item fetch and all enrichment joins in a single + // background read so the sheet opens without blocking the main actor. + private func reloadFromStore(includeScreenshots: Bool) async { + let targetCrawlKey = crawlKey + let fallbackItems = items + let normalizedStorefront = storefrontCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if let backgroundModelStore = services.backgroundModelStore { + let loaded: ([KeywordRankingListItem], EnrichmentPayload)? = try? await backgroundModelStore.read { modelContext in + let loadedItems = Self.fetchRankingItems(crawlKey: targetCrawlKey, in: modelContext) ?? fallbackItems + let payload = Self.makeEnrichmentPayload( + appStoreIDs: loadedItems.map(\.appStoreID), + normalizedStorefront: normalizedStorefront, + includeScreenshots: includeScreenshots, + in: modelContext + ) + return (loadedItems, payload) + } + guard let loaded else { return } + applyEnrichment(items: loaded.0, payload: loaded.1, includeScreenshots: includeScreenshots) + } else { + let loadedItems = Self.fetchRankingItems(crawlKey: targetCrawlKey, in: modelContext) ?? fallbackItems + let payload = Self.makeEnrichmentPayload( + appStoreIDs: loadedItems.map(\.appStoreID), + normalizedStorefront: normalizedStorefront, + includeScreenshots: includeScreenshots, + in: modelContext + ) + applyEnrichment(items: loadedItems, payload: payload, includeScreenshots: includeScreenshots) + } + } + + private func applyEnrichment( + items newItems: [KeywordRankingListItem], + payload: EnrichmentPayload, + includeScreenshots: Bool + ) { + items = newItems + storefrontLanguageCode = payload.storefront?.languageCode + storefrontFlagEmoji = payload.storefront?.flagEmoji + + guard !newItems.isEmpty else { + enrichedRows = [] + enrichedRowsIncludeScreenshots = false + return + } + + enrichedRows = newItems.map { item in + KeywordRankingCatalogRow( + item: item, + storeApp: payload.storeAppsByID[item.appStoreID], + storefrontMetadata: payload.storefrontMetadataByID[item.appStoreID], + usMetadata: payload.usMetadataByID[item.appStoreID], + latestRating: payload.latestByID[item.appStoreID], + ratingSnapshots: payload.snapshotsByID[item.appStoreID] ?? [] + ) + } + enrichedRowsIncludeScreenshots = includeScreenshots + } + + nonisolated private static func fetchRankingItems( + crawlKey: String?, + in modelContext: ModelContext + ) -> [KeywordRankingListItem]? { + guard let crawlKey else { return nil } let targetCrawlKey = crawlKey let descriptor = FetchDescriptor( @@ -179,47 +241,55 @@ struct KeywordRankingListSheet: View { ] ) - items = ((try? modelContext.fetch(descriptor)) ?? []) + return ((try? modelContext.fetch(descriptor)) ?? []) .map(KeywordRankingAppSummary.init) .map { KeywordRankingListItem(result: $0) } } - private func loadEnrichedRows(includeScreenshots: Bool) { - let appStoreIDs = items.map(\.appStoreID) - let appStoreIDSet = Set(appStoreIDs) - let normalizedStorefront = storefrontCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + nonisolated private static func makeEnrichmentPayload( + appStoreIDs: [Int64], + normalizedStorefront: String, + includeScreenshots: Bool, + in modelContext: ModelContext + ) -> EnrichmentPayload { + let storefront = Self.storefrontDisplay(for: normalizedStorefront, in: modelContext) guard !appStoreIDs.isEmpty else { - enrichedRows = [] - enrichedRowsIncludeScreenshots = false - return + return EnrichmentPayload( + storefront: storefront, + storeAppsByID: [:], + storefrontMetadataByID: [:], + usMetadataByID: [:], + latestByID: [:], + snapshotsByID: [:] + ) } + let appStoreIDSet = Set(appStoreIDs) let storeAppsDescriptor = FetchDescriptor( predicate: #Predicate { app in appStoreIDs.contains(app.appStoreID) } ) let storeApps = (try? modelContext.fetch(storeAppsDescriptor)) ?? [] - let catalogAppsByID = Dictionary( + let storeAppsByID = Dictionary( uniqueKeysWithValues: storeApps .map { ($0.appStoreID, StoreAppDisplayValue($0)) } ) - let storefrontDisplay = storefrontDisplay(for: normalizedStorefront) - storefrontLanguageCode = storefrontDisplay?.languageCode - storefrontFlagEmoji = storefrontDisplay?.flagEmoji let storefrontMetadataByID = storefrontMetadataByAppStoreID( storefront: normalizedStorefront, appStoreIDSet: appStoreIDSet, - includeScreenshots: includeScreenshots + includeScreenshots: includeScreenshots, + in: modelContext ) let usMetadataByID = normalizedStorefront == "us" ? storefrontMetadataByID : storefrontMetadataByAppStoreID( storefront: "us", appStoreIDSet: appStoreIDSet, - includeScreenshots: includeScreenshots + includeScreenshots: includeScreenshots, + in: modelContext ) let latestDescriptor = FetchDescriptor( @@ -246,20 +316,20 @@ struct KeywordRankingListSheet: View { let snapshotsByID = Dictionary(grouping: ((try? modelContext.fetch(snapshotDescriptor)) ?? []) .map(RatingSnapshotDisplayValue.init), by: \.appStoreID) - enrichedRows = items.map { item in - KeywordRankingCatalogRow( - item: item, - storeApp: catalogAppsByID[item.appStoreID], - storefrontMetadata: storefrontMetadataByID[item.appStoreID], - usMetadata: usMetadataByID[item.appStoreID], - latestRating: latestByID[item.appStoreID], - ratingSnapshots: snapshotsByID[item.appStoreID] ?? [] - ) - } - enrichedRowsIncludeScreenshots = includeScreenshots + return EnrichmentPayload( + storefront: storefront, + storeAppsByID: storeAppsByID, + storefrontMetadataByID: storefrontMetadataByID, + usMetadataByID: usMetadataByID, + latestByID: latestByID, + snapshotsByID: snapshotsByID + ) } - private func storefrontDisplay(for storefront: String) -> StorefrontDisplayValue? { + nonisolated private static func storefrontDisplay( + for storefront: String, + in modelContext: ModelContext + ) -> StorefrontDisplayValue? { let targetStorefront = storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let descriptor = FetchDescriptor( predicate: #Predicate { storefront in @@ -269,10 +339,11 @@ struct KeywordRankingListSheet: View { return (try? modelContext.fetch(descriptor).first).map(StorefrontDisplayValue.init) } - private func storefrontMetadataByAppStoreID( + nonisolated private static func storefrontMetadataByAppStoreID( storefront: String, appStoreIDSet: Set, - includeScreenshots: Bool + includeScreenshots: Bool, + in modelContext: ModelContext ) -> [Int64: AppStorefrontMetadataDisplayValue] { let targetStorefront = storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let descriptor = FetchDescriptor( @@ -401,6 +472,15 @@ struct KeywordRankingListSheet: View { .openASOPreviewEnvironment(previewContainer) } +private struct EnrichmentPayload: Sendable { + let storefront: StorefrontDisplayValue? + let storeAppsByID: [Int64: StoreAppDisplayValue] + let storefrontMetadataByID: [Int64: AppStorefrontMetadataDisplayValue] + let usMetadataByID: [Int64: AppStorefrontMetadataDisplayValue] + let latestByID: [Int64: RatingLatestDisplayValue] + let snapshotsByID: [Int64: [RatingSnapshotDisplayValue]] +} + private struct KeywordRankingCatalogRow: Identifiable { let item: KeywordRankingListItem let storeApp: StoreAppDisplayValue? @@ -552,7 +632,7 @@ private struct KeywordRankingCatalogRow: Identifiable { } } -private struct StorefrontDisplayValue { +private struct StorefrontDisplayValue: Sendable { let flagEmoji: String let languageCode: String @@ -562,7 +642,7 @@ private struct StorefrontDisplayValue { } } -private struct StoreAppDisplayValue { +private struct StoreAppDisplayValue: Sendable { let appStoreID: Int64 let name: String let subtitle: String? @@ -586,7 +666,7 @@ private struct StoreAppDisplayValue { } } -private struct AppStorefrontMetadataDisplayValue { +private struct AppStorefrontMetadataDisplayValue: Sendable { let appStoreID: Int64 let name: String let subtitle: String? @@ -702,7 +782,7 @@ private struct ScreenshotPlatformGroup: Identifiable, Hashable, Sendable { } } -private struct RatingLatestDisplayValue { +private struct RatingLatestDisplayValue: Sendable { let appStoreID: Int64 let ratingCount: Int? let averageRating: Double? @@ -714,7 +794,7 @@ private struct RatingLatestDisplayValue { } } -private struct RatingSnapshotDisplayValue { +private struct RatingSnapshotDisplayValue: Sendable { let appStoreID: Int64 let ratingDate: String let ratingCount: Int? diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingResultRow.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingResultRow.swift index 2dbcd1c..4073c60 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingResultRow.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingResultRow.swift @@ -33,7 +33,7 @@ import SwiftUI .openASOPreviewEnvironment(previewContainer, allowsIconNetworkFetches: true) } -struct KeywordRankingListItem: Identifiable { +struct KeywordRankingListItem: Identifiable, Sendable { let id: Int64 let position: Int let appStoreID: Int64 diff --git a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift index 15e9b49..b7d8705 100644 --- a/OpenASO/Services/AppDetail/AppDetailRefreshService.swift +++ b/OpenASO/Services/AppDetail/AppDetailRefreshService.swift @@ -180,6 +180,8 @@ private actor AppDetailRefreshQueue { final class AppDetailRefreshService: Sendable { private static let rankingFetchConcurrency = 4 + // Mirrors AppStorefrontRatingService.ratingFetchConcurrency. + private static let reviewFetchConcurrency = 6 private static let rankingPersistenceBatchSize = 5 private let refreshQueue = AppDetailRefreshQueue() @@ -247,26 +249,15 @@ final class AppDetailRefreshService: Sendable { let pricingError: OpenASOError? switch request.workspace { - case .keywords: + case .keywords, .ratings: pricingComparison = nil pricingError = nil - keywordOutcomes = await refreshKeywords(request) - if request.refreshRatings || request.refreshReviews { - (ratingOutcomes, reviewOutcomes) = await refreshRatingsAndReviews(request) - } else { - ratingOutcomes = [] - reviewOutcomes = [] - } - case .ratings: - pricingComparison = nil - pricingError = nil - if request.refreshRatings || request.refreshReviews { - (ratingOutcomes, reviewOutcomes) = await refreshRatingsAndReviews(request) - } else { - ratingOutcomes = [] - reviewOutcomes = [] - } - keywordOutcomes = await refreshKeywords(request) + // Keywords and ratings/reviews touch disjoint models and endpoints; + // run them concurrently and let writes serialize on the model store. + async let keywordsTask = refreshKeywords(request) + async let ratingsReviewsTask = refreshRatingsAndReviews(request) + keywordOutcomes = await keywordsTask + (ratingOutcomes, reviewOutcomes) = await ratingsReviewsTask case .pricing: keywordOutcomes = [] ratingOutcomes = [] @@ -508,6 +499,9 @@ final class AppDetailRefreshService: Sendable { while let (workItem, result) = await group.next() { activeFetchCount -= 1 + // Refill the freed fetch slot before any persistence work so the + // fetch window stays saturated while batches flush to the store. + enqueueNextFetchIfPossible() switch result { case .success(let pageResult): @@ -540,8 +534,6 @@ final class AppDetailRefreshService: Sendable { total: totalRequestedCount, failureCount: failureCount + missingFailureCount ) - - enqueueNextFetchIfPossible() } } @@ -681,62 +673,91 @@ final class AppDetailRefreshService: Sendable { return ([], []) } - do { - let storefrontCodes = request.storefrontSelection.codes - let ratingOutcomes: [AppStorefrontRatingRefreshOutcome] - if request.refreshRatings { - await progressStore?.updatePhase(.refreshingRatings) - ratingOutcomes = await appStorefrontRatingService.fetchRatingOutcomes( - appStoreID: request.app.appStoreID, - appName: request.app.name, - storefronts: storefrontCodes, - progress: { completed, total, failureCount in - await self.progressStore?.updateStep( - .ratings, - status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, - completed: completed, - total: total, - failureCount: failureCount - ) - } - ) - try await persistRatingOutcomes(ratingOutcomes, for: request.app) - } else { - await progressStore?.updateStep(.ratings, status: .skipped, completed: 0, total: 0, failureCount: 0) - ratingOutcomes = [] - } + let storefrontCodes = request.storefrontSelection.codes + // Ratings and reviews hit disjoint endpoints; overlap the network legs + // while writes stay serialized on the background model store. + async let ratingsTask = refreshRatingsBranch(request, storefrontCodes: storefrontCodes) + async let reviewsTask = refreshReviewsBranch(request, storefrontCodes: storefrontCodes) + let outcomes = (await ratingsTask, await reviewsTask) + if request.recordsRatingsReviewsRefresh, didSuccessfullyRefreshRatingsOrReviews(outcomes) { + await ratingsReviewsRefreshRecorder?(.now) + } + return outcomes + } + + private func refreshRatingsBranch( + _ request: AppDetailRefreshRequest, + storefrontCodes: [String] + ) async -> [AppStorefrontRatingRefreshOutcome] { + guard request.refreshRatings else { + await progressStore?.updateStep(.ratings, status: .skipped, completed: 0, total: 0, failureCount: 0) + return [] + } - let reviewOutcomes: [AppStorefrontReviewRefreshOutcome] - if request.refreshReviews { - await progressStore?.updatePhase(.refreshingReviews) - reviewOutcomes = try await refreshReviews( - request: request, - storefrontCodes: storefrontCodes + await progressStore?.updatePhase(.refreshingRatings) + let ratingOutcomes = await appStorefrontRatingService.fetchRatingOutcomes( + appStoreID: request.app.appStoreID, + appName: request.app.name, + storefronts: storefrontCodes, + progress: { completed, total, failureCount in + await self.progressStore?.updateStep( + .ratings, + status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, + completed: completed, + total: total, + failureCount: failureCount ) - } else { - await progressStore?.updateStep(.reviews, status: .skipped, completed: 0, total: 0, failureCount: 0) - reviewOutcomes = [] - } - let outcomes = (ratingOutcomes, reviewOutcomes) - if request.recordsRatingsReviewsRefresh, didSuccessfullyRefreshRatingsOrReviews(outcomes) { - await ratingsReviewsRefreshRecorder?(.now) } - return outcomes + ) + do { + try await persistRatingOutcomes(ratingOutcomes, for: request.app) + return ratingOutcomes } catch { - let mappedError = OpenASOError.map(error) await progressStore?.updateStep( .ratings, status: .failed, - completed: request.storefrontSelection.codes.count, - total: request.storefrontSelection.codes.count, - failureCount: max(1, request.storefrontSelection.codes.count) + completed: storefrontCodes.count, + total: storefrontCodes.count, + failureCount: max(1, storefrontCodes.count) ) - let ratingOutcome = AppStorefrontRatingRefreshOutcome( - storefront: "all", - result: nil, - error: mappedError + return [ + AppStorefrontRatingRefreshOutcome( + storefront: "all", + result: nil, + error: OpenASOError.map(error) + ) + ] + } + } + + private func refreshReviewsBranch( + _ request: AppDetailRefreshRequest, + storefrontCodes: [String] + ) async -> [AppStorefrontReviewRefreshOutcome] { + guard request.refreshReviews else { + await progressStore?.updateStep(.reviews, status: .skipped, completed: 0, total: 0, failureCount: 0) + return [] + } + + await progressStore?.updatePhase(.refreshingReviews) + do { + return try await refreshReviews(request: request, storefrontCodes: storefrontCodes) + } catch { + await progressStore?.updateStep( + .reviews, + status: .failed, + completed: storefrontCodes.count, + total: storefrontCodes.count, + failureCount: max(1, storefrontCodes.count) ) - return ([ratingOutcome], []) + return [ + AppStorefrontReviewRefreshOutcome( + storefront: "all", + fetchedReviews: 0, + storedReviews: 0, + error: OpenASOError.map(error) + ) + ] } } @@ -850,50 +871,80 @@ final class AppDetailRefreshService: Sendable { var failureCount = 0 await progressStore?.updateStep(.reviews, status: .running, completed: 0, total: targetStorefronts.count, failureCount: 0) - for storefront in targetStorefronts { - do { - var storedCount = 0 - let fetchedCount = try await appStorefrontReviewService.fetchReviewPages( - appStoreID: request.app.appStoreID, - storefront: storefront - ) { pageReviews in - let pageStoredCount = try await backgroundModelStore.write { modelContext in - let storeApp = try storeApp(for: request.app, in: modelContext) - return try appStorefrontReviewService.upsert( - pageReviews, - storeApp: storeApp, - in: modelContext + // Overlap the per-storefront review crawls with a bounded window + // (mirrors AppStorefrontRatingService); writes stay serialized on the + // background model store. + await withTaskGroup(of: AppStorefrontReviewRefreshOutcome.self) { group in + var nextIndex = 0 + var activeCount = 0 + + func enqueueNextFetchIfPossible() { + guard activeCount < Self.reviewFetchConcurrency, + nextIndex < targetStorefronts.count else { + return + } + + let storefront = targetStorefronts[nextIndex] + nextIndex += 1 + activeCount += 1 + group.addTask { + do { + var storedCount = 0 + let fetchedCount = try await self.appStorefrontReviewService.fetchReviewPages( + appStoreID: request.app.appStoreID, + storefront: storefront + ) { pageReviews in + let pageStoredCount = try await self.backgroundModelStore.write { modelContext in + let storeApp = try self.storeApp(for: request.app, in: modelContext) + return try self.appStorefrontReviewService.upsert( + pageReviews, + storeApp: storeApp, + in: modelContext + ) + } + storedCount += pageStoredCount + return pageStoredCount == pageReviews.count + } + return AppStorefrontReviewRefreshOutcome( + storefront: storefront, + fetchedReviews: fetchedCount, + storedReviews: storedCount, + error: nil + ) + } catch { + return AppStorefrontReviewRefreshOutcome( + storefront: storefront, + fetchedReviews: 0, + storedReviews: 0, + error: OpenASOError.map(error) ) } - storedCount += pageStoredCount - return pageStoredCount == pageReviews.count } - outcomes.append(AppStorefrontReviewRefreshOutcome( - storefront: storefront, - fetchedReviews: fetchedCount, - storedReviews: storedCount, - error: nil - )) - } catch { - failureCount += 1 - outcomes.append(AppStorefrontReviewRefreshOutcome( - storefront: storefront, - fetchedReviews: 0, - storedReviews: 0, - error: OpenASOError.map(error) - )) } - completedCount += 1 - await progressStore?.updateStep( - .reviews, - status: completedCount >= targetStorefronts.count ? (failureCount > 0 ? .failed : .completed) : .running, - completed: completedCount, - total: targetStorefronts.count, - failureCount: failureCount - ) + for _ in 0..= targetStorefronts.count ? (failureCount > 0 ? .failed : .completed) : .running, + completed: completedCount, + total: targetStorefronts.count, + failureCount: failureCount + ) + } } + outcomes.sort { $0.storefront < $1.storefront } return outcomes } diff --git a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift index 2086e18..37029db 100644 --- a/OpenASO/Services/AppleAds/AppleAdsWebSession.swift +++ b/OpenASO/Services/AppleAds/AppleAdsWebSession.swift @@ -1047,16 +1047,54 @@ struct AppleAdsCMPopularityClient: Sendable { let terms = Self.uniqueTerms(from: keywords) guard !terms.isEmpty else { return [:] } + let batches = terms.chunked(into: Self.maxTermsPerRequest) var popularities: [String: Int] = [:] - for batch in terms.chunked(into: Self.maxTermsPerRequest) { - let response = try await keywordPopularitiesBatch( - for: batch, - storefrontCode: storefrontCode, - adamId: adamId, - session: session - ) - for keyword in response.data { - popularities[Self.normalizedKeywordKey(keyword.name)] = keyword.popularity + + // Fast path: a single chunk needs no task group. + if batches.count <= 1 { + if let batch = batches.first { + let response = try await keywordPopularitiesBatch( + for: batch, + storefrontCode: storefrontCode, + adamId: adamId, + session: session + ) + for keyword in response.data { + popularities[Self.normalizedKeywordKey(keyword.name)] = keyword.popularity + } + } + return popularities + } + + // Overlap chunk requests with a small window to avoid hammering the + // private endpoint. + let maxConcurrentChunks = 3 + try await withThrowingTaskGroup(of: KeywordPopularityCMResponse.self) { group in + var nextIndex = 0 + + func addNextTaskIfPossible() { + guard nextIndex < batches.count else { return } + let batch = batches[nextIndex] + nextIndex += 1 + group.addTask { + try await self.keywordPopularitiesBatch( + for: batch, + storefrontCode: storefrontCode, + adamId: adamId, + session: session + ) + } + } + + for _ in 0..() let tracks = try modelContext.fetch(descriptor) let uniqueTracks = Dictionary(grouping: tracks, by: \.queryKey).compactMapValues(\.first) + let metricsByKey = try self.metricsMap(for: Array(uniqueTracks.keys), in: modelContext) return uniqueTracks.values .filter { track in Self.shouldRefreshMetrics( metricsTTL: metricsTTL, - for: track.queryKey, - in: modelContext + metric: metricsByKey[track.queryKey] ) } .map(\.identityKey) } } - private func persistMetricsPayload( - _ payload: KeywordMetricsPayload, - for candidate: KeywordMetricsRefreshCandidate, + private func persistMetricsPayloads( + _ entries: [(candidate: KeywordMetricsRefreshCandidate, payload: KeywordMetricsPayload)], using modelStore: BackgroundModelStore - ) async throws -> KeywordMetricsRefreshOutcome { - try await modelStore.write { modelContext in - try Self.applyMetricsPayload( - payload, - forTrackIdentityKey: candidate.trackIdentityKey, - fallbackTrackID: candidate.trackID, - in: modelContext + ) async throws -> [KeywordMetricsRefreshOutcome] { + guard !entries.isEmpty else { return [] } + + return try await modelStore.write { modelContext in + let targetIdentityKeys = entries.map(\.candidate.trackIdentityKey) + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + targetIdentityKeys.contains(track.identityKey) + } ) + let tracks = try modelContext.fetch(descriptor) + let tracksByIdentityKey = Dictionary(uniqueKeysWithValues: tracks.map { ($0.identityKey, $0) }) + var metricsCache = try self.metricsMap(for: entries.map(\.candidate.queryKey), in: modelContext) + + return entries.map { entry in + guard let track = tracksByIdentityKey[entry.candidate.trackIdentityKey] else { + return KeywordMetricsRefreshOutcome( + trackID: entry.candidate.trackID, + errorMessage: OpenASOError.appNotFound.localizedDescription + ) + } + + Self.upsertMetrics(entry.payload, for: track, in: modelContext, cache: &metricsCache) + if let statusMessage = entry.payload.statusMessage { + track.statusMessage = statusMessage + return KeywordMetricsRefreshOutcome(trackID: track.persistentModelID, errorMessage: statusMessage) + } else { + Self.clearPopularityStatusIfNeeded(for: track) + return KeywordMetricsRefreshOutcome(trackID: track.persistentModelID, errorMessage: nil) + } + } } } @@ -361,30 +396,6 @@ final class KeywordMetricsService: Sendable { } } - private static func applyMetricsPayload( - _ payload: KeywordMetricsPayload, - forTrackIdentityKey trackIdentityKey: String, - fallbackTrackID: PersistentIdentifier, - in modelContext: ModelContext - ) throws -> KeywordMetricsRefreshOutcome { - let targetIdentityKey = trackIdentityKey - let descriptor = FetchDescriptor( - predicate: #Predicate { track in - track.identityKey == targetIdentityKey - } - ) - guard let track = try modelContext.fetch(descriptor).first else { - return KeywordMetricsRefreshOutcome( - trackID: fallbackTrackID, - errorMessage: OpenASOError.appNotFound.localizedDescription - ) - } - - var outcomes: [KeywordMetricsRefreshOutcome] = [] - applyMetricsPayload(payload, for: track, in: modelContext, outcomes: &outcomes) - return outcomes.last ?? KeywordMetricsRefreshOutcome(trackID: fallbackTrackID, errorMessage: nil) - } - private static func applyMetricsPayload( _ payload: KeywordMetricsPayload, for track: TrackedAppKeyword, @@ -402,11 +413,15 @@ final class KeywordMetricsService: Sendable { } private static func shouldRefreshMetrics(metricsTTL: TimeInterval, for queryKey: String, in modelContext: ModelContext) -> Bool { - guard let metrics = try? fetchMetrics(queryKey: queryKey, in: modelContext) else { + shouldRefreshMetrics(metricsTTL: metricsTTL, metric: try? fetchMetrics(queryKey: queryKey, in: modelContext)) + } + + private static func shouldRefreshMetrics(metricsTTL: TimeInterval, metric: KeywordDailyMetric?) -> Bool { + guard let metric else { return true } - return metrics.popularityScore == nil || Date.now.timeIntervalSince(metrics.updatedAt) >= metricsTTL + return metric.popularityScore == nil || Date.now.timeIntervalSince(metric.updatedAt) >= metricsTTL } private static func fetchMetrics(queryKey: String, in modelContext: ModelContext) throws -> KeywordDailyMetric? { @@ -420,8 +435,21 @@ final class KeywordMetricsService: Sendable { } private static func upsertMetrics(_ payload: KeywordMetricsPayload, for track: TrackedAppKeyword, in modelContext: ModelContext) { - let metrics: KeywordDailyMetric + var cache: [String: KeywordDailyMetric] = [:] if let existing = try? fetchMetrics(queryKey: track.queryKey, in: modelContext) { + cache[track.queryKey] = existing + } + upsertMetrics(payload, for: track, in: modelContext, cache: &cache) + } + + private static func upsertMetrics( + _ payload: KeywordMetricsPayload, + for track: TrackedAppKeyword, + in modelContext: ModelContext, + cache: inout [String: KeywordDailyMetric] + ) { + let metrics: KeywordDailyMetric + if let existing = cache[track.queryKey] { metrics = existing } else { metrics = KeywordDailyMetric( @@ -439,6 +467,7 @@ final class KeywordMetricsService: Sendable { notes: payload.notes ) modelContext.insert(metrics) + cache[track.queryKey] = metrics } metrics.keyword = track.term @@ -516,6 +545,15 @@ final class KeywordMetricsService: Sendable { ) } + private static func isRetryablePopularityError(_ error: OpenASOError) -> Bool { + switch error { + case .rateLimited, .networkUnavailable: + return true + default: + return false + } + } + private static func isUnsupportedAppleAdsStorefrontMessage(_ message: String) -> Bool { let lowercasedMessage = message.lowercased() return lowercasedMessage.contains("apple ads") @@ -544,6 +582,7 @@ private struct KeywordMetricsRefreshCandidate: Sendable { let trackIdentityKey: String let term: String let storefront: String + let queryKey: String let shouldRefresh: Bool } diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 25b3224..ec293c8 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -1404,26 +1404,17 @@ final class OpenASOMCPService: Sendable { }.map(\.identityKey) } - var refreshErrorsByIdentityKey: [String: String] = [:] + var refreshOutcomes: [KeywordMetricsRefreshOutcome]? = nil + var precomputedErrorsByIdentityKey: [String: String] = [:] if let keywordMetricsService { let popularityContextAppStoreID = await popularityContextAppStoreIDProvider() let webSession = await appleAdsWebSessionProvider() - let refreshOutcomes = try await keywordMetricsService.refreshMetrics( + refreshOutcomes = try await keywordMetricsService.refreshMetrics( for: trackIdentityKeys, popularityContextAppStoreID: popularityContextAppStoreID, webSession: webSession, using: backgroundModelStore ) - refreshErrorsByIdentityKey = try await backgroundModelStore.read { modelContext in - var errors: [String: String] = [:] - for outcome in refreshOutcomes where outcome.errorMessage != nil { - guard let track = modelContext.model(for: outcome.trackID) as? TrackedAppKeyword else { - continue - } - errors[track.identityKey] = outcome.errorMessage - } - return errors - } } else { let message = "Popularity failed to fetch. Connect an Apple Ads web session in Settings." try await backgroundModelStore.write { modelContext in @@ -1433,12 +1424,25 @@ final class OpenASOMCPService: Sendable { track.statusMessage = message } } - refreshErrorsByIdentityKey = Dictionary( + precomputedErrorsByIdentityKey = Dictionary( uniqueKeysWithValues: trackIdentityKeys.map { ($0, message) }) } - let refreshErrors = refreshErrorsByIdentityKey + // Resolve outcome errors inside the final read so only one background + // round trip runs after the refresh. + let capturedOutcomes = refreshOutcomes + let capturedPrecomputedErrors = precomputedErrorsByIdentityKey return try await backgroundModelStore.read { modelContext in + var refreshErrors = capturedPrecomputedErrors + if let capturedOutcomes { + for outcome in capturedOutcomes where outcome.errorMessage != nil { + guard let track = modelContext.model(for: outcome.trackID) as? TrackedAppKeyword else { + continue + } + refreshErrors[track.identityKey] = outcome.errorMessage + } + } + let tracks = try trackIdentityKeys.compactMap { try Self.fetchTrackedKeyword(identityKey: $0, in: modelContext) } diff --git a/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift b/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift index cd73a0f..4f65dee 100644 --- a/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift +++ b/OpenASO/Services/SearchRanking/RankingRefreshCoordinator.swift @@ -31,6 +31,11 @@ struct RankingRefreshRequest: Sendable { } } +private struct RankingRefreshCoordinatorWorkItem: Sendable { + let fetchRequest: RankingRefreshRequest + let targetRequests: [RankingRefreshRequest] +} + struct RankingRefreshPageResult: Sendable { let request: RankingRefreshRequest let page: SearchRankingPage @@ -917,60 +922,89 @@ final class RankingRefreshCoordinator: Sendable { } let rankingRequests = tracks.map(RankingRefreshRequest.init) + // Group by queryKey so each unique term+storefront+platform is fetched + // once and the page is fanned out to every sharing track. + let workItems: [RankingRefreshCoordinatorWorkItem] = + Dictionary(grouping: rankingRequests, by: \.queryKey) + .values + .map { groupedRequests in + let orderedRequests = groupedRequests.sorted { lhs, rhs in + lhs.identityKey.localizedStandardCompare(rhs.identityKey) == .orderedAscending + } + return RankingRefreshCoordinatorWorkItem( + fetchRequest: orderedRequests[0], + targetRequests: orderedRequests + ) + } + .sorted { lhs, rhs in + lhs.fetchRequest.queryKey.localizedStandardCompare(rhs.fetchRequest.queryKey) == .orderedAscending + } let rankingPageFetcher = makeRankingPageFetcher(limit: limit) - await withTaskGroup(of: (RankingRefreshRequest, Result).self) { group in - var nextRequestIndex = 0 + await withTaskGroup(of: (RankingRefreshCoordinatorWorkItem, Result).self) { group in + var nextWorkItemIndex = 0 var activeFetchCount = 0 func enqueueNextFetchIfPossible() { guard activeFetchCount < Self.rankingFetchConcurrency, - nextRequestIndex < rankingRequests.count + nextWorkItemIndex < workItems.count else { return } - let rankingRequest = rankingRequests[nextRequestIndex] - nextRequestIndex += 1 + let workItem = workItems[nextWorkItemIndex] + nextWorkItemIndex += 1 activeFetchCount += 1 group.addTask { - let result = await rankingPageFetcher(rankingRequest) - return (rankingRequest, result) + let result = await rankingPageFetcher(workItem.fetchRequest) + return (workItem, result) } } - for _ in 0.. 1 ? "shared_query_fetch" : pageResult.confidence + )) + } if pendingPageResults.count >= Self.rankingPersistenceBatchSize { flushPendingPageResults() } case .failure(let error): - _ = try? recordRefreshFailure( - identityKey: rankingRequest.identityKey, - error: error, - in: modelContext, - saveChanges: false - ) - outcomes.append(RefreshOutcome( - trackID: tracks.first { $0.identityKey == rankingRequest.identityKey }?.persistentModelID ?? tracks[0].persistentModelID, - snapshotID: nil, - rank: nil, - searchedAt: nil, - error: error - )) - failureCount += 1 + for targetRequest in workItem.targetRequests { + _ = try? recordRefreshFailure( + identityKey: targetRequest.identityKey, + error: error, + in: modelContext, + saveChanges: false + ) + outcomes.append(RefreshOutcome( + trackID: tracks.first { $0.identityKey == targetRequest.identityKey }?.persistentModelID ?? tracks[0].persistentModelID, + snapshotID: nil, + rank: nil, + searchedAt: nil, + error: error + )) + failureCount += 1 + } } - completedCount += 1 + completedCount += workItem.targetRequests.count await progress?(completedCount, tracks.count, failureCount) - enqueueNextFetchIfPossible() } } diff --git a/OpenASOTests/AppServicesDependencyTests.swift b/OpenASOTests/AppServicesDependencyTests.swift index f9295b0..c5dba57 100644 --- a/OpenASOTests/AppServicesDependencyTests.swift +++ b/OpenASOTests/AppServicesDependencyTests.swift @@ -6,13 +6,16 @@ import Testing @MainActor struct AppServicesDependencyTests { @Test - func modelContainerFactoryUsesV1MigrationPlan() throws { + func modelContainerFactoryUsesV2MigrationPlan() throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) #expect(OpenASOSchemaV1.versionIdentifier == Schema.Version(1, 0, 0)) - #expect(OpenASOMigrationPlan.schemas.count == 1) + #expect(OpenASOSchemaV2.versionIdentifier == Schema.Version(1, 1, 0)) + #expect(OpenASOMigrationPlan.schemas.count == 2) #expect(OpenASOMigrationPlan.schemas.first?.versionIdentifier == OpenASOSchemaV1.versionIdentifier) + #expect(OpenASOMigrationPlan.schemas.last?.versionIdentifier == OpenASOSchemaV2.versionIdentifier) #expect(OpenASOSchemaV1.models.count == 17) + #expect(OpenASOSchemaV2.models.count == 18) #expect(container.migrationPlan != nil) } @@ -180,7 +183,8 @@ struct AppServicesDependencyTests { session: session ) - #expect(requestBodies.map(\.terms.count) == [100, 1]) + // Chunks are fetched concurrently, so request order is nondeterministic. + #expect(requestBodies.map(\.terms.count).sorted() == [1, 100]) #expect(requestBodies.allSatisfy { $0.storefronts == ["US"] }) #expect(requestHeaders["Accept"] == "application/json") #expect(requestHeaders["Content-Type"] == "application/json") diff --git a/OpenASOTests/RankingRefreshCoordinatorTests.swift b/OpenASOTests/RankingRefreshCoordinatorTests.swift index 2b7c451..da4b6b8 100644 --- a/OpenASOTests/RankingRefreshCoordinatorTests.swift +++ b/OpenASOTests/RankingRefreshCoordinatorTests.swift @@ -467,7 +467,7 @@ struct RankingRefreshCoordinatorTests { refreshCoordinator: coordinator, appDetailRefresh: { request in requests.append(request) - return AppDetailRefreshResult(keywordOutcomes: [], ratingOutcomes: [], reviewOutcomes: [], firstError: nil) + return AppDetailRefreshResult(keywordOutcomes: [], ratingOutcomes: [], reviewOutcomes: [], pricingComparison: nil, pricingError: nil, firstError: nil) }, storefrontCodesProvider: { ["US", "gb"] } ) @@ -526,7 +526,7 @@ struct RankingRefreshCoordinatorTests { refreshCoordinator: coordinator, appDetailRefresh: { request in requests.append(request) - return AppDetailRefreshResult(keywordOutcomes: [], ratingOutcomes: [], reviewOutcomes: [], firstError: nil) + return AppDetailRefreshResult(keywordOutcomes: [], ratingOutcomes: [], reviewOutcomes: [], pricingComparison: nil, pricingError: nil, firstError: nil) }, storefrontCodesProvider: { ["us"] } ) From ee8c34e0f7ac77b3d87811c12668ac994afb5b6c Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 01:08:03 +0530 Subject: [PATCH 3/8] fix: Make global keyword list storefront-agnostic and stop auto-pushing to apps - Global keywords are now plain reusable terms (no per-country/device copies); legacy per-storefront entries collapse to unique terms on load - Applying global keywords in Add Keywords fans them out across the currently selected countries and device - Remove ensureGlobalKeywordTracks so refreshes never silently add global keywords to an app; they are only added explicitly - Manage Keyword Lists edits global terms without country/device pickers - Simplify MCP global keyword tools (list/add/update) to term-only Co-Authored-By: Claude Fable 5 --- .../Features/AppDetail/AppDetailView.swift | 65 +----- .../AppDetail/Keywords/AddKeywordsSheet.swift | 208 ++++++++---------- OpenASO/Services/MCP/OpenASOMCPDTOs.swift | 2 - OpenASO/Services/MCP/OpenASOMCPServer.swift | 29 +-- OpenASO/Services/MCP/OpenASOMCPService.swift | 67 ++---- 5 files changed, 114 insertions(+), 257 deletions(-) diff --git a/OpenASO/Features/AppDetail/AppDetailView.swift b/OpenASO/Features/AppDetail/AppDetailView.swift index 99d2593..752a31c 100644 --- a/OpenASO/Features/AppDetail/AppDetailView.swift +++ b/OpenASO/Features/AppDetail/AppDetailView.swift @@ -39,7 +39,6 @@ struct AppDetailView: View { @State private var queuedKeywordAdds: [KeywordAddRequest] = [] @State private var isFlushingQueuedKeywordAdds = false @State private var isRefreshingQueuedKeywordAdds = false - @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" init(trackedApp: TrackedApp) { self.trackedApp = trackedApp @@ -738,70 +737,12 @@ struct AppDetailView: View { storefrontSelection: AppDetailRefreshStorefrontSelection, platformFilter: PlatformFilter ) throws -> [TrackedAppKeyword] { - try ensureGlobalKeywordTracks( - for: app, - storefrontSelection: storefrontSelection, - platformFilter: platformFilter - ) - - return try fetchTrackedKeywords(for: app.appStoreID, platformFilter: platformFilter) + // Global keywords are only added to an app explicitly (via Add + // Keywords); refreshes never auto-materialize them as tracks. + try fetchTrackedKeywords(for: app.appStoreID, platformFilter: platformFilter) .filter { matchesStorefrontSelection($0.storefront, selection: storefrontSelection) } } - private func ensureGlobalKeywordTracks( - for app: TrackedApp, - storefrontSelection: AppDetailRefreshStorefrontSelection, - platformFilter: PlatformFilter - ) throws { - let templates = globalKeywordTemplates.filter { - matchesStorefrontSelection($0.storefront, selection: storefrontSelection) - && platformFilter.matches($0.platform) - } - guard !templates.isEmpty else { return } - - var existingKeys = Set( - try fetchTrackedKeywords(for: app.appStoreID) - .map { keywordDuplicateKey(term: $0.term, storefront: $0.storefront, platform: $0.platform) } - ) - var insertedAny = false - - for template in templates { - let duplicateKey = keywordDuplicateKey( - term: template.term, - storefront: template.storefront, - platform: template.platform - ) - guard existingKeys.insert(duplicateKey).inserted else { continue } - - let query = try KeywordQuery.fetchOrInsert( - term: template.term, - storefront: template.storefront, - platform: template.platform, - in: modelContext - ) - let track = TrackedAppKeyword( - term: template.term, - storefront: template.storefront, - platform: template.platform, - trackedApp: app, - query: query - ) - track.notes = "Added from global keyword list." - app.keywordTracks.append(track) - modelContext.insert(track) - insertedAny = true - } - - if insertedAny { - try modelContext.save() - keywordRefreshToken += 1 - } - } - - private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { - GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) - } - private func matchesStorefrontSelection( _ storefront: String, selection: AppDetailRefreshStorefrontSelection diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index b293a93..d1e0fcd 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -149,11 +149,14 @@ struct AddKeywordsSheet: View { @ViewBuilder private var reusableKeywordsSection: some View { - let keywords = reusableKeywordsForSelection + let keywords = sortedGlobalKeywords VStack(alignment: .leading, spacing: 8) { HStack { Text("Reusable Keywords") .font(.headline) + Text("Applied to every selected country and the selected device.") + .font(.callout) + .foregroundStyle(.secondary) Spacer() if !keywords.isEmpty { Button("Select All") { @@ -168,20 +171,13 @@ struct AddKeywordsSheet: View { } if keywords.isEmpty { - Text("No reusable keywords match the selected device and countries yet.") + Text("No reusable keywords yet. Save pasted keywords to build the global list.") .font(.callout) .foregroundStyle(.secondary) } else { List(keywords) { keyword in Toggle(isOn: reusableKeywordBinding(for: keyword.id)) { - HStack(spacing: 10) { - Text(keyword.term) - Spacer() - Text(keyword.storefront.uppercased()) - .foregroundStyle(.secondary) - Text(keyword.platform.displayName) - .foregroundStyle(.secondary) - } + Text(keyword.term) } } .frame(minHeight: 110, maxHeight: 150) @@ -222,18 +218,10 @@ struct AddKeywordsSheet: View { .mapValues(\.count) } - private var reusableKeywordsForSelection: [GlobalTrackedKeywordTemplate] { - globalKeywordTemplates - .filter { template in - template.platform == selectedPlatform - && selectedStorefrontCodes.contains(template.storefront) - } - .sorted { lhs, rhs in - if lhs.storefront == rhs.storefront { - return lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending - } - return lhs.storefront < rhs.storefront - } + private var sortedGlobalKeywords: [GlobalTrackedKeywordTemplate] { + globalKeywordTemplates.sorted { lhs, rhs in + lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending + } } private func keywordCount(for storefrontCode: String) -> Int { @@ -249,8 +237,6 @@ struct AddKeywordsSheet: View { selectedStorefrontCodes.insert(code) } else { selectedStorefrontCodes.remove(code) - selectedGlobalKeywordIDs.subtract( - globalKeywordTemplates.filter { $0.storefront == code }.map(\.id)) } } ) @@ -438,8 +424,7 @@ struct AddKeywordsSheet: View { } if savesInputToGlobalKeywords, !typedKeywords.isEmpty { - persistGlobalKeywords( - keywords: typedKeywords, storefrontCodes: storefrontCodes, platform: platform) + persistGlobalKeywords(keywords: typedKeywords) } do { @@ -521,8 +506,11 @@ struct AddKeywordsSheet: View { var seen = Set() var candidates: [KeywordAddCandidate] = [] + // Typed keywords and selected global keywords both fan out across the + // selected countries and device. + let keywordsToAdd = parsedKeywords + selectedGlobalKeywords.map(\.term) for storefrontCode in selectedStorefrontCodes.sorted() { - for keyword in parsedKeywords { + for keyword in keywordsToAdd { let candidate = KeywordAddCandidate( keyword: keyword, storefrontCode: storefrontCode, @@ -533,35 +521,17 @@ struct AddKeywordsSheet: View { } } - for template in selectedGlobalKeywords { - let candidate = KeywordAddCandidate( - keyword: template.term, - storefrontCode: template.storefront, - platform: template.platform - ) - guard seen.insert(candidate.identityKey).inserted else { continue } - candidates.append(candidate) - } - return candidates } - private func persistGlobalKeywords( - keywords: [String], storefrontCodes: Set, platform: AppPlatform - ) { + private func persistGlobalKeywords(keywords: [String]) { var templates = globalKeywordTemplates var existingIDs = Set(templates.map(\.id)) - for storefrontCode in storefrontCodes.sorted() { - for keyword in keywords { - let template = GlobalTrackedKeywordTemplate( - term: keyword, - storefront: storefrontCode, - platform: platform - ) - guard existingIDs.insert(template.id).inserted else { continue } - templates.append(template) - } + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate(term: keyword) + guard existingIDs.insert(template.id).inserted else { continue } + templates.append(template) } globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) @@ -602,36 +572,35 @@ struct KeywordAddCandidate: Hashable { } } +// A global keyword is just a reusable term. Countries and device are chosen +// at apply time, so the list never needs one copy per storefront. struct GlobalTrackedKeywordTemplate: Codable, Identifiable, Hashable { let term: String - let storefront: String - let platformRaw: String let createdAt: Date - init(term: String, storefront: String, platform: AppPlatform, createdAt: Date = .now) { + init(term: String, createdAt: Date = .now) { self.term = term.trimmingCharacters(in: .whitespacesAndNewlines) - self.storefront = storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self.platformRaw = platform.rawValue self.createdAt = createdAt } var id: String { - [ - term.normalizedKeywordKey, - storefront, - platformRaw, - ].joined(separator: "::") + term.normalizedKeywordKey } - var platform: AppPlatform { - AppPlatform(rawValue: platformRaw) ?? .iphone + private enum CodingKeys: String, CodingKey { + case term + case createdAt } static func decodeList(from json: String) -> [GlobalTrackedKeywordTemplate] { guard let data = json.data(using: .utf8) else { return [] } - return (try? JSONDecoder().decode([GlobalTrackedKeywordTemplate].self, from: data)) ?? [] + let decoded = (try? JSONDecoder().decode([GlobalTrackedKeywordTemplate].self, from: data)) ?? [] + // Legacy lists stored one copy per storefront and device; collapse + // them to unique terms. + var seenIDs = Set() + return decoded.filter { !$0.term.isEmpty && seenIDs.insert($0.id).inserted } } static func encodeList(_ templates: [GlobalTrackedKeywordTemplate]) -> String { @@ -718,6 +687,7 @@ struct ManageKeywordListsSheet: View { title: entry.title, draft: entry.draft, allowsMultipleKeywords: entry.allowsMultipleKeywords, + showsTargetingControls: entry.showsTargetingControls, storefronts: editorStorefronts(including: entry.draft.storefront) ) { draft in save(entry: entry, draft: draft) @@ -806,8 +776,8 @@ struct ManageKeywordListsSheet: View { List(globalKeywordTemplates) { template in KeywordListRow( title: template.term, - storefront: storeTitle(for: template.storefront), - platform: template.platform.displayName, + storefront: nil, + platform: nil, notes: nil, editAction: { editingEntry = .global(template) @@ -825,13 +795,7 @@ struct ManageKeywordListsSheet: View { private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) .sorted { lhs, rhs in - if lhs.storefront != rhs.storefront { - return lhs.storefront < rhs.storefront - } - if lhs.platformRaw != rhs.platformRaw { - return lhs.platformRaw < rhs.platformRaw - } - return lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending + lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending } } @@ -843,14 +807,14 @@ struct ManageKeywordListsSheet: View { errorMessage = "Enter a keyword." return } - guard !normalizedDraft.storefront.isEmpty else { - errorMessage = "Choose a country." - return - } do { switch entry.source { case .local(let track): + guard !normalizedDraft.storefront.isEmpty else { + errorMessage = "Choose a country." + return + } try saveLocalKeyword(track, draft: normalizedDraft) case .global(let existingID): try saveGlobalKeywords(existingID: existingID, draft: normalizedDraft) @@ -915,17 +879,12 @@ struct ManageKeywordListsSheet: View { try modelContext.save() } - private func saveGlobalKeyword(existingID: String?, draft: KeywordListDraft) throws { - let replacement = GlobalTrackedKeywordTemplate( - term: draft.term, - storefront: draft.storefront, - platform: draft.platform - ) + private func saveGlobalKeyword(existingID: String?, term: String) throws { + let replacement = GlobalTrackedKeywordTemplate(term: term) var templates = globalKeywordTemplates.filter { $0.id != existingID } guard !templates.contains(where: { $0.id == replacement.id }) else { - throw OpenASOError.providerUnavailable( - "That global keyword, country, and device already exist.") + throw OpenASOError.providerUnavailable("That keyword is already in the global list.") } templates.append(replacement) @@ -939,15 +898,7 @@ struct ManageKeywordListsSheet: View { } if existingID != nil || keywords.count == 1 { - try saveGlobalKeyword( - existingID: existingID, - draft: KeywordListDraft( - term: keywords[0], - storefront: draft.storefront, - platform: draft.platform, - notes: draft.notes - ) - ) + try saveGlobalKeyword(existingID: existingID, term: keywords[0]) return } @@ -956,19 +907,14 @@ struct ManageKeywordListsSheet: View { var insertedCount = 0 for keyword in keywords { - let template = GlobalTrackedKeywordTemplate( - term: keyword, - storefront: draft.storefront, - platform: draft.platform - ) + let template = GlobalTrackedKeywordTemplate(term: keyword) guard existingIDs.insert(template.id).inserted else { continue } templates.append(template) insertedCount += 1 } guard insertedCount > 0 else { - throw OpenASOError.providerUnavailable( - "Those global keywords already exist for this country and device.") + throw OpenASOError.providerUnavailable("Those keywords are already in the global list.") } globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) @@ -1132,8 +1078,8 @@ private enum KeywordListResetConfirmation: Identifiable { private struct KeywordListRow: View { let title: String - let storefront: String - let platform: String + let storefront: String? + let platform: String? let notes: String? let editAction: () -> Void let deleteAction: () -> Void @@ -1144,8 +1090,12 @@ private struct KeywordListRow: View { Text(title) .font(.headline) HStack(spacing: 8) { - Text(storefront) - Text(platform) + if let storefront { + Text(storefront) + } + if let platform { + Text(platform) + } if let notes, !notes.isEmpty { Text(notes) .lineLimit(1) @@ -1173,6 +1123,7 @@ private struct KeywordListEntryEditor: View { let title: String let allowsMultipleKeywords: Bool + let showsTargetingControls: Bool let storefronts: [KeywordListStorefrontOption] let save: (KeywordListDraft) -> Void @@ -1182,11 +1133,13 @@ private struct KeywordListEntryEditor: View { title: String, draft: KeywordListDraft, allowsMultipleKeywords: Bool, + showsTargetingControls: Bool, storefronts: [KeywordListStorefrontOption], save: @escaping (KeywordListDraft) -> Void ) { self.title = title self.allowsMultipleKeywords = allowsMultipleKeywords + self.showsTargetingControls = showsTargetingControls self.storefronts = storefronts self.save = save _draft = State(initialValue: draft) @@ -1213,23 +1166,29 @@ private struct KeywordListEntryEditor: View { .textFieldStyle(.roundedBorder) } - Picker("Country", selection: $draft.storefront) { - ForEach(storefronts) { storefront in - Text(storefront.title) - .tag(storefront.code) + if showsTargetingControls { + Picker("Country", selection: $draft.storefront) { + ForEach(storefronts) { storefront in + Text(storefront.title) + .tag(storefront.code) + } } - } - Picker("Device", selection: $draft.platform) { - ForEach(AppPlatform.allCases) { platform in - Label(platform.displayName, systemImage: platform.keywordSheetSystemImage) - .tag(platform) + Picker("Device", selection: $draft.platform) { + ForEach(AppPlatform.allCases) { platform in + Label(platform.displayName, systemImage: platform.keywordSheetSystemImage) + .tag(platform) + } } - } - .pickerStyle(.segmented) + .pickerStyle(.segmented) - TextField("Notes", text: $draft.notes, axis: .vertical) - .textFieldStyle(.roundedBorder) + TextField("Notes", text: $draft.notes, axis: .vertical) + .textFieldStyle(.roundedBorder) + } else { + Text("Global keywords apply to the countries and device you choose when adding them to an app.") + .font(.callout) + .foregroundStyle(.secondary) + } HStack { Spacer() @@ -1283,6 +1242,13 @@ private struct KeywordListEditingEntry: Identifiable { return false } + var showsTargetingControls: Bool { + if case .local = source { + return true + } + return false + } + static func local(_ track: TrackedAppKeyword) -> KeywordListEditingEntry { KeywordListEditingEntry( source: .local(track), @@ -1302,8 +1268,8 @@ private struct KeywordListEditingEntry: Identifiable { title: "Edit Global Keyword", draft: KeywordListDraft( term: template.term, - storefront: template.storefront, - platform: template.platform, + storefront: "", + platform: .iphone, notes: "" ) ) @@ -1312,10 +1278,10 @@ private struct KeywordListEditingEntry: Identifiable { static func newGlobal(defaultPlatform: AppPlatform) -> KeywordListEditingEntry { KeywordListEditingEntry( source: .global(existingID: nil), - title: "Add Global Keyword", + title: "Add Global Keywords", draft: KeywordListDraft( term: "", - storefront: "us", + storefront: "", platform: defaultPlatform, notes: "" ) diff --git a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift index dd377b0..d105404 100644 --- a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift +++ b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift @@ -227,8 +227,6 @@ struct OpenASOMCPKeywordTrackMutationResult: Codable, Sendable { struct OpenASOMCPGlobalKeywordTemplate: Codable, Identifiable, Sendable { let id: String let keyword: String - let storefront: String - let platform: String let createdAt: Date } diff --git a/OpenASO/Services/MCP/OpenASOMCPServer.swift b/OpenASO/Services/MCP/OpenASOMCPServer.swift index 4bdb703..42875a9 100644 --- a/OpenASO/Services/MCP/OpenASOMCPServer.swift +++ b/OpenASO/Services/MCP/OpenASOMCPServer.swift @@ -217,10 +217,7 @@ struct OpenASOMCPServerFactory: Sendable { return try Self.toolResult(result) case "list_global_keywords": - let result = try service.listGlobalKeywords( - storefronts: arguments.stringArray("storefronts"), - platform: arguments.string("platform") - ) + let result = try service.listGlobalKeywords() return try Self.toolResult(result) case "score_keywords": @@ -242,9 +239,7 @@ struct OpenASOMCPServerFactory: Sendable { case "add_global_keywords": let result = try service.addGlobalKeywords( - keywords: try arguments.requiredStringArray("keywords"), - storefronts: try arguments.requiredStringArray("storefronts"), - platform: arguments.string("platform") + keywords: try arguments.requiredStringArray("keywords") ) return try Self.toolResult(result) @@ -283,9 +278,7 @@ struct OpenASOMCPServerFactory: Sendable { case "update_global_keyword": let result = try service.updateGlobalKeyword( id: try arguments.requiredString("id"), - keyword: try arguments.requiredString("keyword"), - storefront: try arguments.requiredString("storefront"), - platform: arguments.string("platform") + keyword: try arguments.requiredString("keyword") ) return try Self.toolResult(result) @@ -550,8 +543,8 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer, "cursor": .string]) { current, _ in current } ), readOnly: true), - tool("list_global_keywords", "List reusable global keyword templates.", schema( - optional: ["storefronts": .stringArray, "platform": .string] + tool("list_global_keywords", "List the reusable global keyword terms. Global keywords are storefront-agnostic; use add_keywords to apply them to an app for specific storefronts.", schema( + optional: [:] ), readOnly: true), tool("score_keywords", "Classify tracked keywords into defend, attack, long-tail, brand, experimental, or noisy buckets using rank, popularity, and phrase-quality heuristics.", schema( required: ["appStoreID"], @@ -561,9 +554,9 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID", "keywords", "storefronts"], optional: ["appStoreID": .integer, "keywords": .stringArray, "storefronts": .stringArray, "platform": .string] ), readOnly: false, destructive: false, idempotent: true), - tool("add_global_keywords", "Add reusable global keyword templates.", schema( - required: ["keywords", "storefronts"], - optional: ["keywords": .stringArray, "storefronts": .stringArray, "platform": .string] + tool("add_global_keywords", "Add reusable global keyword terms. Global keywords are storefront-agnostic and are not tracked for any app until applied with add_keywords.", schema( + required: ["keywords"], + optional: ["keywords": .stringArray] ), readOnly: false, destructive: false, idempotent: true), tool("update_keyword_notes", "Update notes for one tracked keyword.", schema( required: ["appStoreID", "keyword", "storefront", "notes"], @@ -584,9 +577,9 @@ private extension OpenASOMCPServerFactory { optional: ["track_identity_key": .string] ), readOnly: false, destructive: true, idempotent: false), tool("reset_keywords", "Delete all tracked keywords for one app.", appIDSchema, readOnly: false, destructive: true, idempotent: false), - tool("update_global_keyword", "Edit one reusable global keyword template by id.", schema( - required: ["id", "keyword", "storefront"], - optional: ["id": .string, "keyword": .string, "storefront": .string, "platform": .string] + tool("update_global_keyword", "Rename one reusable global keyword term by id.", schema( + required: ["id", "keyword"], + optional: ["id": .string, "keyword": .string] ), readOnly: false, destructive: false, idempotent: true), tool("delete_global_keyword", "Delete one reusable global keyword template by id.", schema( required: ["id"], diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index ec293c8..753cad8 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -408,49 +408,25 @@ final class OpenASOMCPService: Sendable { } } - func listGlobalKeywords( - storefronts: [String]? = nil, - platform: String? = nil - ) throws -> [OpenASOMCPGlobalKeywordTemplate] { - let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) - let platform = try platform.map(OpenASOMCPValidation.platform) - return Self.globalKeywordTemplates().filter { template in - if !storefronts.isEmpty && !storefronts.contains(template.storefront) { return false } - if let platform, template.platformRaw != platform.rawValue { return false } - return true - }.map(Self.globalKeywordTemplate) + func listGlobalKeywords() throws -> [OpenASOMCPGlobalKeywordTemplate] { + Self.globalKeywordTemplates().map(Self.globalKeywordTemplate) } - func addGlobalKeywords( - keywords: [String], - storefronts: [String], - platform: String? = nil - ) throws -> OpenASOMCPGlobalKeywordMutationResult { + func addGlobalKeywords(keywords: [String]) throws -> OpenASOMCPGlobalKeywordMutationResult { let keywords = try OpenASOMCPValidation.keywords(keywords) - let storefronts = try OpenASOMCPValidation.storefronts(storefronts) - let platform = try OpenASOMCPValidation.platform(platform) - guard !storefronts.isEmpty else { - throw OpenASOError.providerUnavailable("Select at least one storefront.") - } var templates = Self.globalKeywordTemplates() var existingIDs = Set(templates.map(\.id)) var insertedCount = 0 var skippedCount = 0 - for storefront in storefronts { - for keyword in keywords { - let template = GlobalTrackedKeywordTemplate( - term: keyword, - storefront: storefront, - platform: platform - ) - if existingIDs.insert(template.id).inserted { - templates.append(template) - insertedCount += 1 - } else { - skippedCount += 1 - } + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate(term: keyword) + if existingIDs.insert(template.id).inserted { + templates.append(template) + insertedCount += 1 + } else { + skippedCount += 1 } } @@ -469,28 +445,21 @@ final class OpenASOMCPService: Sendable { func updateGlobalKeyword( id: String, - keyword: String, - storefront: String, - platform: String? = nil + keyword: String ) throws -> OpenASOMCPGlobalKeywordMutationResult { let keyword = try OpenASOMCPValidation.keyword(keyword) - let storefront = try OpenASOMCPValidation.storefront(storefront) - let platform = try OpenASOMCPValidation.platform(platform) var templates = Self.globalKeywordTemplates() guard let index = templates.firstIndex(where: { $0.id == id }) else { throw OpenASOError.appNotFound } let replacement = GlobalTrackedKeywordTemplate( term: keyword, - storefront: storefront, - platform: platform, createdAt: templates[index].createdAt ) guard !templates.enumerated().contains(where: { offset, template in offset != index && template.id == replacement.id }) else { - throw OpenASOError.providerUnavailable( - "That global keyword, country, and device already exist.") + throw OpenASOError.providerUnavailable("That keyword is already in the global list.") } templates[index] = replacement Self.setGlobalKeywordTemplates(templates) @@ -3964,15 +3933,7 @@ extension OpenASOMCPService { fileprivate static func globalKeywordTemplates() -> [GlobalTrackedKeywordTemplate] { let json = UserDefaults.standard.string(forKey: globalKeywordTemplatesKey) ?? "[]" return GlobalTrackedKeywordTemplate.decodeList(from: json) - .sorted { - if $0.storefront == $1.storefront { - if $0.platformRaw == $1.platformRaw { - return $0.term.localizedStandardCompare($1.term) == .orderedAscending - } - return $0.platformRaw < $1.platformRaw - } - return $0.storefront < $1.storefront - } + .sorted { $0.term.localizedStandardCompare($1.term) == .orderedAscending } } fileprivate static func setGlobalKeywordTemplates(_ templates: [GlobalTrackedKeywordTemplate]) { @@ -3988,8 +3949,6 @@ extension OpenASOMCPService { OpenASOMCPGlobalKeywordTemplate( id: template.id, keyword: template.term, - storefront: template.storefront, - platform: template.platform.rawValue, createdAt: template.createdAt ) } From 6c6e98e8e3ab835d68b4df4b641c39a3ce59b6ad Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 01:16:31 +0530 Subject: [PATCH 4/8] feat: Apply global keywords to all apps with optional replace mode - New Apply to Apps sheet on the Global keyword list: adds every global keyword to all tracked apps for chosen countries and device - Replace mode removes keywords in the selected countries/device that are not in the global list (clears their ranking history), enabling a clean reset of legacy US-only tracks - Keyword queries resolved with one batch fetch shared across apps Co-Authored-By: Claude Fable 5 --- .../AppDetail/Keywords/AddKeywordsSheet.swift | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index d1e0fcd..efb7b5f 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -630,6 +630,7 @@ struct ManageKeywordListsSheet: View { @State private var editingEntry: KeywordListEditingEntry? @State private var resetConfirmation: KeywordListResetConfirmation? @State private var errorMessage: String? + @State private var isShowingApplySheet = false init(trackedApp: TrackedApp) { self.trackedApp = trackedApp @@ -703,6 +704,12 @@ struct ManageKeywordListsSheet: View { secondaryButton: .cancel() ) } + .sheet(isPresented: $isShowingApplySheet) { + GlobalKeywordApplySheet( + templates: globalKeywordTemplates, + defaultPlatform: trackedApp.defaultPlatform + ) + } } private var localKeywordList: some View { @@ -757,6 +764,13 @@ struct ManageKeywordListsSheet: View { } label: { Label("Add Global Keyword", systemImage: "plus") } + Button { + isShowingApplySheet = true + } label: { + Label("Apply to Apps", systemImage: "square.and.arrow.down.on.square") + } + .disabled(globalKeywordTemplates.isEmpty) + .help("Add the global keywords to every tracked app for chosen countries") Button(role: .destructive) { resetConfirmation = .global(count: globalKeywordTemplates.count) } label: { @@ -1017,6 +1031,254 @@ struct ManageKeywordListsSheet: View { } } +private struct GlobalKeywordApplySheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Environment(AppServices.self) private var services + + @Query(sort: [SortDescriptor(\Storefront.name, order: .forward)]) + private var storefronts: [Storefront] + + @Query(sort: [SortDescriptor(\TrackedApp.name, order: .forward)]) + private var trackedApps: [TrackedApp] + + let templates: [GlobalTrackedKeywordTemplate] + + @State private var selectedStorefrontCodes: Set = ["us"] + @State private var selectedPlatform: AppPlatform + @State private var removesKeywordsMissingFromGlobalList = false + @State private var storefrontSearchText = "" + @State private var statusMessage: String? + @State private var errorMessage: String? + @State private var isApplying = false + + init(templates: [GlobalTrackedKeywordTemplate], defaultPlatform: AppPlatform) { + self.templates = templates + _selectedPlatform = State(initialValue: defaultPlatform) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Apply Global Keywords to All Apps") + .font(.title3) + .bold() + + Text( + "Adds the \(templates.count.formatted()) global keywords to every tracked app (\(trackedApps.count.formatted()) apps) for the selected countries and device. Existing keywords are kept unless you enable replace." + ) + .foregroundStyle(.secondary) + + Picker("Device", selection: $selectedPlatform) { + ForEach(AppPlatform.allCases) { platform in + Label(platform.displayName, systemImage: platform.keywordSheetSystemImage) + .tag(platform) + } + } + .pickerStyle(.segmented) + .disabled(isApplying) + + Text("Countries") + .font(.headline) + + AddKeywordsStorefrontSearchField(storefrontSearchText: $storefrontSearchText) + .disabled(isApplying) + + List(filteredStorefronts, id: \.code) { storefront in + Toggle(isOn: storefrontBinding(for: storefront.code)) { + Text(storefront.title) + } + } + .frame(minHeight: 200) + .disabled(isApplying) + + Toggle( + "Replace: remove keywords in the selected countries and device that are not in the global list", + isOn: $removesKeywordsMissingFromGlobalList + ) + .disabled(isApplying) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } else if let statusMessage { + Text(statusMessage) + .foregroundStyle(.secondary) + } else if removesKeywordsMissingFromGlobalList { + Text("Ranking history for removed keywords is deleted.") + .foregroundStyle(.orange) + } + + HStack { + Spacer() + Button(statusMessage == nil ? "Cancel" : "Done") { + dismiss() + } + Button(removesKeywordsMissingFromGlobalList ? "Replace Keywords" : "Add Keywords") { + apply() + } + .keyboardShortcut(.defaultAction) + .disabled( + isApplying || templates.isEmpty || selectedStorefrontCodes.isEmpty + || trackedApps.isEmpty + ) + } + } + .padding(24) + .frame(minWidth: 560, minHeight: 640) + } + + private var filteredStorefronts: [Storefront] { + let normalizedSearch = storefrontSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSearch.isEmpty else { return storefronts } + return storefronts.filter { storefront in + storefront.title.localizedCaseInsensitiveContains(normalizedSearch) + || storefront.code.localizedCaseInsensitiveContains(normalizedSearch) + } + } + + private func storefrontBinding(for code: String) -> Binding { + Binding( + get: { selectedStorefrontCodes.contains(code) }, + set: { isSelected in + if isSelected { + selectedStorefrontCodes.insert(code) + } else { + selectedStorefrontCodes.remove(code) + } + } + ) + } + + private func apply() { + isApplying = true + errorMessage = nil + statusMessage = nil + + let storefrontCodes = selectedStorefrontCodes + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + .sorted() + let storefrontCodeSet = Set(storefrontCodes) + let terms = templates.map(\.term) + let globalTermKeys = Set(terms.map(\.normalizedKeywordKey)) + let platform = selectedPlatform + + do { + // Resolve every needed keyword query with one batch fetch; the + // same queries are shared by all apps. + let neededQueryKeys = Array(Set(storefrontCodes.flatMap { storefront in + terms.map { + KeywordQuery.makeQueryKey(term: $0, storefront: storefront, platform: platform) + } + })) + var queriesByKey: [String: KeywordQuery] = [:] + let descriptor = FetchDescriptor( + predicate: #Predicate { query in + neededQueryKeys.contains(query.queryKey) + } + ) + for query in try modelContext.fetch(descriptor) { + queriesByKey[query.queryKey] = query + } + + var insertedCount = 0 + var removedCount = 0 + + for app in trackedApps { + if removesKeywordsMissingFromGlobalList { + let tracksToRemove = app.keywordTracks.filter { track in + storefrontCodeSet.contains( + track.storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + ) + && track.platform == platform + && !globalTermKeys.contains(track.term.normalizedKeywordKey) + } + for track in tracksToRemove { + deleteSnapshots(for: track) + modelContext.delete(track) + removedCount += 1 + } + } + + var existingKeys = Set() + for track in app.keywordTracks where !track.isDeleted { + existingKeys.insert( + trackKey(term: track.term, storefront: track.storefront, platform: track.platform) + ) + } + + for storefront in storefrontCodes { + for term in terms { + let key = trackKey(term: term, storefront: storefront, platform: platform) + guard existingKeys.insert(key).inserted else { continue } + + let queryKey = KeywordQuery.makeQueryKey( + term: term, storefront: storefront, platform: platform) + let query: KeywordQuery + if let existing = queriesByKey[queryKey] { + query = existing + } else { + query = KeywordQuery(term: term, storefront: storefront, platform: platform) + modelContext.insert(query) + queriesByKey[queryKey] = query + } + + let track = TrackedAppKeyword( + term: term, + storefront: storefront, + platform: platform, + trackedApp: app, + query: query + ) + track.notes = "Added from global keyword list." + app.keywordTracks.append(track) + modelContext.insert(track) + insertedCount += 1 + } + } + } + + try modelContext.save() + if insertedCount > 0 { + services.analyticsService.capture( + .keywordAdded(keywordCount: terms.count, storefrontCount: storefrontCodes.count)) + } + if removedCount > 0 { + services.analyticsService.capture(.keywordDeleted(deleteCount: removedCount)) + } + var summary = + "Added \(insertedCount.formatted()) keyword tracks across \(trackedApps.count.formatted()) apps." + if removesKeywordsMissingFromGlobalList { + summary += " Removed \(removedCount.formatted()) keyword tracks not in the global list." + } + summary += " Refresh apps to collect rankings." + statusMessage = summary + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + isApplying = false + } + + private func trackKey(term: String, storefront: String, platform: AppPlatform) -> String { + [ + term.normalizedKeywordKey, + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform.rawValue, + ].joined(separator: "::") + } + + private func deleteSnapshots(for track: TrackedAppKeyword) { + for snapshot in track.snapshots { + for result in snapshot.topResults { + modelContext.delete(result) + } + snapshot.topResults.removeAll() + modelContext.delete(snapshot) + } + track.snapshots.removeAll() + } +} + private enum KeywordListScope: CaseIterable, Identifiable { case local case global From 9da616b88cc0fe2d48d846965289f31f521f5c51 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 01:25:45 +0530 Subject: [PATCH 5/8] feat: Central Global Keywords manager in the sidebar - Extract the global keyword list editor into a shared GlobalKeywordListEditorView - New sidebar footer entry opens the global list manager directly, without going through an app - Per-app Keyword Lists sheet embeds the same editor on its Global tab; its local scope logic is now local-only Co-Authored-By: Claude Fable 5 --- OpenASO/App/RootSidebarView.swift | 21 + .../AppDetail/Keywords/AddKeywordsSheet.swift | 361 +++++++++++------- 2 files changed, 235 insertions(+), 147 deletions(-) diff --git a/OpenASO/App/RootSidebarView.swift b/OpenASO/App/RootSidebarView.swift index c5017d5..4177253 100644 --- a/OpenASO/App/RootSidebarView.swift +++ b/OpenASO/App/RootSidebarView.swift @@ -37,6 +37,8 @@ struct RootSidebarView: View { @State private var hoveredAppID: Int64? @State private var isPresentingMCPServer = false @State private var isMCPHovered = false + @State private var isPresentingGlobalKeywords = false + @State private var isGlobalKeywordsHovered = false @State private var isSettingsHovered = false private var unfiledApps: [TrackedApp] { @@ -70,6 +72,9 @@ struct RootSidebarView: View { settingsStore: services.settingsStore ) } + .sheet(isPresented: $isPresentingGlobalKeywords) { + GlobalKeywordsSheet() + } .alert("Rename Folder", isPresented: $isPresentingRenameFolder) { TextField("Folder Name", text: $folderName) @@ -248,6 +253,22 @@ struct RootSidebarView: View { .stroke(Color.secondary.opacity(0.15)) ) + Button { + isPresentingGlobalKeywords = true + } label: { + SidebarUtilityRow( + title: "Global Keywords", + systemImage: "globe", + state: nil, + isHovered: isGlobalKeywordsHovered + ) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .onHover { isGlobalKeywordsHovered = $0 } + .animation(.easeInOut(duration: 0.12), value: isGlobalKeywordsHovered) + .help("Manage the shared global keyword list and apply it to apps") + Button { isPresentingMCPServer = true } label: { diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index efb7b5f..fdf373c 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -622,7 +622,6 @@ struct ManageKeywordListsSheet: View { private var storefronts: [Storefront] @Query private var localKeywords: [TrackedAppKeyword] - @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" let trackedApp: TrackedApp @@ -630,7 +629,6 @@ struct ManageKeywordListsSheet: View { @State private var editingEntry: KeywordListEditingEntry? @State private var resetConfirmation: KeywordListResetConfirmation? @State private var errorMessage: String? - @State private var isShowingApplySheet = false init(trackedApp: TrackedApp) { self.trackedApp = trackedApp @@ -678,7 +676,7 @@ struct ManageKeywordListsSheet: View { case .local: localKeywordList case .global: - globalKeywordList + GlobalKeywordListEditorView(defaultPlatform: trackedApp.defaultPlatform) } } .padding(24) @@ -704,12 +702,6 @@ struct ManageKeywordListsSheet: View { secondaryButton: .cancel() ) } - .sheet(isPresented: $isShowingApplySheet) { - GlobalKeywordApplySheet( - templates: globalKeywordTemplates, - defaultPlatform: trackedApp.defaultPlatform - ) - } } private var localKeywordList: some View { @@ -753,66 +745,6 @@ struct ManageKeywordListsSheet: View { } } - private var globalKeywordList: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("\(globalKeywordTemplates.count.formatted()) reusable global keywords") - .foregroundStyle(.secondary) - Spacer() - Button { - editingEntry = .newGlobal(defaultPlatform: trackedApp.defaultPlatform) - } label: { - Label("Add Global Keyword", systemImage: "plus") - } - Button { - isShowingApplySheet = true - } label: { - Label("Apply to Apps", systemImage: "square.and.arrow.down.on.square") - } - .disabled(globalKeywordTemplates.isEmpty) - .help("Add the global keywords to every tracked app for chosen countries") - Button(role: .destructive) { - resetConfirmation = .global(count: globalKeywordTemplates.count) - } label: { - Label("Reset Global Keywords", systemImage: "trash") - } - .disabled(globalKeywordTemplates.isEmpty) - } - - if globalKeywordTemplates.isEmpty { - ContentUnavailableView( - "No Global Keywords", - systemImage: "globe", - description: Text("Add reusable keywords here or save pasted keywords from Add Keywords.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(globalKeywordTemplates) { template in - KeywordListRow( - title: template.term, - storefront: nil, - platform: nil, - notes: nil, - editAction: { - editingEntry = .global(template) - }, - deleteAction: { - deleteGlobalKeyword(template) - } - ) - } - .listStyle(.plain) - } - } - } - - private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { - GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) - .sorted { lhs, rhs in - lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending - } - } - private func save(entry: KeywordListEditingEntry, draft: KeywordListDraft) { errorMessage = nil let normalizedDraft = draft.normalized @@ -830,8 +762,9 @@ struct ManageKeywordListsSheet: View { return } try saveLocalKeyword(track, draft: normalizedDraft) - case .global(let existingID): - try saveGlobalKeywords(existingID: existingID, draft: normalizedDraft) + case .global: + // Global entries are edited by GlobalKeywordListEditorView. + break } editingEntry = nil } catch { @@ -893,47 +826,6 @@ struct ManageKeywordListsSheet: View { try modelContext.save() } - private func saveGlobalKeyword(existingID: String?, term: String) throws { - let replacement = GlobalTrackedKeywordTemplate(term: term) - var templates = globalKeywordTemplates.filter { $0.id != existingID } - - guard !templates.contains(where: { $0.id == replacement.id }) else { - throw OpenASOError.providerUnavailable("That keyword is already in the global list.") - } - - templates.append(replacement) - globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) - } - - private func saveGlobalKeywords(existingID: String?, draft: KeywordListDraft) throws { - let keywords = parsedKeywords(from: draft.term) - guard !keywords.isEmpty else { - throw OpenASOError.providerUnavailable("Enter at least one keyword.") - } - - if existingID != nil || keywords.count == 1 { - try saveGlobalKeyword(existingID: existingID, term: keywords[0]) - return - } - - var templates = globalKeywordTemplates - var existingIDs = Set(templates.map(\.id)) - var insertedCount = 0 - - for keyword in keywords { - let template = GlobalTrackedKeywordTemplate(term: keyword) - guard existingIDs.insert(template.id).inserted else { continue } - templates.append(template) - insertedCount += 1 - } - - guard insertedCount > 0 else { - throw OpenASOError.providerUnavailable("Those keywords are already in the global list.") - } - - globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) - } - private func deleteLocalKeyword(_ track: TrackedAppKeyword) { modelContext.delete(track) do { @@ -944,18 +836,10 @@ struct ManageKeywordListsSheet: View { } } - private func deleteGlobalKeyword(_ template: GlobalTrackedKeywordTemplate) { - globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList( - globalKeywordTemplates.filter { $0.id != template.id } - ) - } - private func reset(_ confirmation: KeywordListResetConfirmation) { switch confirmation { case .local: resetLocalKeywords() - case .global: - resetGlobalKeywords() } } @@ -973,10 +857,6 @@ struct ManageKeywordListsSheet: View { } } - private func resetGlobalKeywords() { - globalKeywordTemplatesJSON = "[]" - } - private func deleteSnapshots(for track: TrackedAppKeyword) { for snapshot in track.snapshots { for result in snapshot.topResults { @@ -996,19 +876,6 @@ struct ManageKeywordListsSheet: View { ].joined(separator: "::") } - private func parsedKeywords(from text: String) -> [String] { - let separators = CharacterSet(charactersIn: ",\n") - let parts = text - .components(separatedBy: separators) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - var seen = Set() - return parts.filter { keyword in - seen.insert(keyword.normalizedKeywordKey).inserted - } - } - private func storeTitle(for code: String) -> String { let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard let storefront = storefronts.first(where: { $0.code.lowercased() == normalizedCode }) else { @@ -1031,6 +898,216 @@ struct ManageKeywordListsSheet: View { } } +// Central editor for the shared global keyword list. Embedded in the per-app +// Keyword Lists sheet and presented standalone from the sidebar. +struct GlobalKeywordsSheet: View { + @Environment(\.dismiss) private var dismiss + + @Query(sort: [SortDescriptor(\TrackedApp.name, order: .forward)]) + private var trackedApps: [TrackedApp] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Global Keywords") + .font(.title2) + .bold() + Spacer() + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + + Text( + "One shared keyword list for all apps. Keywords here are not tracked anywhere until you apply them to apps for the countries and device you choose." + ) + .foregroundStyle(.secondary) + + GlobalKeywordListEditorView( + defaultPlatform: trackedApps.first?.defaultPlatform ?? .iphone + ) + } + .padding(24) + .frame(minWidth: 820, minHeight: 620) + } +} + +private struct GlobalKeywordListEditorView: View { + @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" + + let defaultPlatform: AppPlatform + + @State private var editingEntry: KeywordListEditingEntry? + @State private var isShowingResetConfirmation = false + @State private var isShowingApplySheet = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("\(globalKeywordTemplates.count.formatted()) reusable global keywords") + .foregroundStyle(.secondary) + Spacer() + Button { + editingEntry = .newGlobal(defaultPlatform: defaultPlatform) + } label: { + Label("Add Global Keywords", systemImage: "plus") + } + Button { + isShowingApplySheet = true + } label: { + Label("Apply to Apps", systemImage: "square.and.arrow.down.on.square") + } + .disabled(globalKeywordTemplates.isEmpty) + .help("Add the global keywords to every tracked app for chosen countries") + Button(role: .destructive) { + isShowingResetConfirmation = true + } label: { + Label("Reset Global Keywords", systemImage: "trash") + } + .disabled(globalKeywordTemplates.isEmpty) + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + if globalKeywordTemplates.isEmpty { + ContentUnavailableView( + "No Global Keywords", + systemImage: "globe", + description: Text("Add reusable keywords here or save pasted keywords from Add Keywords.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(globalKeywordTemplates) { template in + KeywordListRow( + title: template.term, + storefront: nil, + platform: nil, + notes: nil, + editAction: { + editingEntry = .global(template) + }, + deleteAction: { + deleteGlobalKeyword(template) + } + ) + } + .listStyle(.plain) + } + } + .sheet(item: $editingEntry) { entry in + KeywordListEntryEditor( + title: entry.title, + draft: entry.draft, + allowsMultipleKeywords: entry.allowsMultipleKeywords, + showsTargetingControls: false, + storefronts: [] + ) { draft in + save(entry: entry, draft: draft) + } + } + .alert("Reset Global Keywords?", isPresented: $isShowingResetConfirmation) { + Button("Reset Global Keywords", role: .destructive) { + globalKeywordTemplatesJSON = "[]" + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "Delete all \(globalKeywordTemplates.count.formatted()) reusable global keywords. App-specific tracked keywords will not be deleted." + ) + } + .sheet(isPresented: $isShowingApplySheet) { + GlobalKeywordApplySheet( + templates: globalKeywordTemplates, + defaultPlatform: defaultPlatform + ) + } + } + + private var globalKeywordTemplates: [GlobalTrackedKeywordTemplate] { + GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + .sorted { lhs, rhs in + lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending + } + } + + private func save(entry: KeywordListEditingEntry, draft: KeywordListDraft) { + errorMessage = nil + guard case .global(let existingID) = entry.source else { return } + + do { + try saveGlobalKeywords(existingID: existingID, term: draft.normalized.term) + editingEntry = nil + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + } + + private func saveGlobalKeyword(existingID: String?, term: String) throws { + let replacement = GlobalTrackedKeywordTemplate(term: term) + var templates = globalKeywordTemplates.filter { $0.id != existingID } + + guard !templates.contains(where: { $0.id == replacement.id }) else { + throw OpenASOError.providerUnavailable("That keyword is already in the global list.") + } + + templates.append(replacement) + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) + } + + private func saveGlobalKeywords(existingID: String?, term: String) throws { + let keywords = parsedKeywords(from: term) + guard !keywords.isEmpty else { + throw OpenASOError.providerUnavailable("Enter at least one keyword.") + } + + if existingID != nil || keywords.count == 1 { + try saveGlobalKeyword(existingID: existingID, term: keywords[0]) + return + } + + var templates = globalKeywordTemplates + var existingIDs = Set(templates.map(\.id)) + var insertedCount = 0 + + for keyword in keywords { + let template = GlobalTrackedKeywordTemplate(term: keyword) + guard existingIDs.insert(template.id).inserted else { continue } + templates.append(template) + insertedCount += 1 + } + + guard insertedCount > 0 else { + throw OpenASOError.providerUnavailable("Those keywords are already in the global list.") + } + + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList(templates) + } + + private func deleteGlobalKeyword(_ template: GlobalTrackedKeywordTemplate) { + globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList( + globalKeywordTemplates.filter { $0.id != template.id } + ) + } + + private func parsedKeywords(from text: String) -> [String] { + let separators = CharacterSet(charactersIn: ",\n") + let parts = text + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var seen = Set() + return parts.filter { keyword in + seen.insert(keyword.normalizedKeywordKey).inserted + } + } +} + private struct GlobalKeywordApplySheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -1297,14 +1374,11 @@ private enum KeywordListScope: CaseIterable, Identifiable { private enum KeywordListResetConfirmation: Identifiable { case local(appName: String, count: Int) - case global(count: Int) var id: String { switch self { case .local: return "local" - case .global: - return "global" } } @@ -1312,8 +1386,6 @@ private enum KeywordListResetConfirmation: Identifiable { switch self { case .local: return "Reset App Keywords?" - case .global: - return "Reset Global Keywords?" } } @@ -1322,9 +1394,6 @@ private enum KeywordListResetConfirmation: Identifiable { case .local(let appName, let count): return "Delete all \(count.formatted()) tracked keywords for \(appName). This removes the app's keyword tracks and ranking history. The app itself stays tracked." - case .global(let count): - return - "Delete all \(count.formatted()) reusable global keywords. App-specific tracked keywords will not be deleted." } } @@ -1332,8 +1401,6 @@ private enum KeywordListResetConfirmation: Identifiable { switch self { case .local: return "Reset App Keywords" - case .global: - return "Reset Global Keywords" } } } From 441b87119fc7c9218ac9497dd3e8ee8058bc6085 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 01:45:47 +0530 Subject: [PATCH 6/8] feat: Auto-sync global keywords to all apps across all markets, with market insights - Global keyword add/edit/delete/reset (UI and MCP) now reconciles every tracked app's keyword tracks automatically across all bundled storefronts; sync-created tracks carry a marker note so manual keywords are never touched - New tracks get rankings and metrics fetched in the background through one deduplicated coordinator pass (shared keyword+market queries fetched once, not per app), with sidebar progress - Remove the manual Apply to Apps sheet, now obsolete - New Market Insights sheet in the keywords toolbar: per keyword, best and worst ranking market, spread, average rank, and ranked-market coverage - New MCP tool list_keyword_market_rankings exposing the same best/worst market data per keyword; global keyword mutation results now include a sync summary Co-Authored-By: Claude Fable 5 --- OpenASO.xcodeproj/project.pbxproj | 451 +++++++++--------- OpenASO/App/RootSidebarView.swift | 2 + .../AppDetail/AppDetailToolbarViews.swift | 12 + .../Features/AppDetail/AppDetailView.swift | 7 + .../AppDetail/Keywords/AddKeywordsSheet.swift | 327 +++---------- .../Keywords/KeywordMarketInsightsSheet.swift | 285 +++++++++++ .../AppDetail/AppRefreshProgressStore.swift | 21 + .../AppDetail/GlobalKeywordSync.swift | 254 ++++++++++ OpenASO/Services/MCP/OpenASOMCPDTOs.swift | 35 ++ OpenASO/Services/MCP/OpenASOMCPServer.swift | 26 +- OpenASO/Services/MCP/OpenASOMCPService.swift | 124 ++++- 11 files changed, 1043 insertions(+), 501 deletions(-) create mode 100644 OpenASO/Features/AppDetail/Keywords/KeywordMarketInsightsSheet.swift create mode 100644 OpenASO/Services/AppDetail/GlobalKeywordSync.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index e75798f..afa53f1 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -7,71 +7,72 @@ objects = { /* Begin PBXBuildFile section */ - 04E8C4E18F1A4D519C0A1101 /* Storefront/AppCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1105 /* Storefront/AppCatalogService.swift */; }; - 04E8C4E18F1A4D519C0A1102 /* Storefront/AppIconStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1106 /* Storefront/AppIconStore.swift */; }; + 04E8C4E18F1A4D519C0A1101 /* AppCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1105 /* AppCatalogService.swift */; }; + 04E8C4E18F1A4D519C0A1102 /* AppIconStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1106 /* AppIconStore.swift */; }; 04E8C4E18F1A4D519C0A1104 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */; }; - 04E8C4E18F1A4D519C0A110A /* AppleAds/AppleAdsCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */; }; + 04E8C4E18F1A4D519C0A110A /* AppleAdsCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110C /* AppleAdsCredentials.swift */; }; 04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110D /* SettingsView.swift */; }; - 04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */; }; + 04E8C4E18F1A4D519C0A110E /* AppleSearchAdsJWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110F /* AppleSearchAdsJWT.swift */; }; 04E8C4E18F1A4D519C0A1110 /* KeywordMetricsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */; }; - 04E8C4E18F1A4D519C0A1111 /* AppleAds/KeywordMetricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */; }; - 04E8C4E18F1A4D519C0A1112 /* SearchRanking/KeywordSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */; }; - 04E8C4E18F1A4D519C0A1123 /* AppleAds/AppleAdsWebSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */; }; - 04E8C4E18F1A4D519C0A1125 /* script/apple_ads_web_session.js in Resources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1126 /* script/apple_ads_web_session.js */; }; + 04E8C4E18F1A4D519C0A1111 /* KeywordMetricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1115 /* KeywordMetricsService.swift */; }; + 04E8C4E18F1A4D519C0A1112 /* KeywordSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1116 /* KeywordSuggestionService.swift */; }; + 04E8C4E18F1A4D519C0A1123 /* AppleAdsWebSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1124 /* AppleAdsWebSession.swift */; }; + 04E8C4E18F1A4D519C0A1125 /* apple_ads_web_session.js in Resources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1126 /* apple_ads_web_session.js */; }; 04E8C4E18F1A4D519C0A1127 /* KeywordMetricsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1128 /* KeywordMetricsServiceTests.swift */; }; - 04E8C4E18F1A4D519C0A1133 /* AI/AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1130 /* AI/AIService.swift */; }; - 04E8C4E18F1A4D519C0A1134 /* AI/AITranslationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1131 /* AI/AITranslationService.swift */; }; - 04E8C4E18F1A4D519C0A1135 /* AI/FoundationModelsAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1132 /* AI/FoundationModelsAIService.swift */; }; + 04E8C4E18F1A4D519C0A1133 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1130 /* AIService.swift */; }; + 04E8C4E18F1A4D519C0A1134 /* AITranslationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1131 /* AITranslationService.swift */; }; + 04E8C4E18F1A4D519C0A1135 /* FoundationModelsAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1132 /* FoundationModelsAIService.swift */; }; 04E8C4E18F1A4D519C0A1136 /* CodableAppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1137 /* CodableAppStorage.swift */; }; 04E8C4E18F1A4D519C0A1138 /* AppleAdsSettingsConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1139 /* AppleAdsSettingsConnectionState.swift */; }; 04E8C4E18F1A4D519C0A113A /* AppleAdsSettingsStatusRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A113B /* AppleAdsSettingsStatusRows.swift */; }; 04E8C4E18F1A4D519C0A113C /* SettingsViewPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A113D /* SettingsViewPreviews.swift */; }; - 04E8C4E18F1A4D519C0A1140 /* AppDetail/AppDetailRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1141 /* AppDetail/AppDetailRefreshService.swift */; }; - 04E8C4E18F1A4D519C0A1142 /* AppDetail/AppRefreshProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1143 /* AppDetail/AppRefreshProgressStore.swift */; }; + 04E8C4E18F1A4D519C0A1140 /* AppDetailRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1141 /* AppDetailRefreshService.swift */; }; + 04E8C4E18F1A4D519C0A1142 /* AppRefreshProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1143 /* AppRefreshProgressStore.swift */; }; 05728FD20BC84DE79D98F6F6 /* AppServicesDependencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC1A541B8A64AB1A36B50E5 /* AppServicesDependencyTests.swift */; }; 0A1B2C3D4E5F678901234505 /* AppKeywordStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F678901234506 /* AppKeywordStats.swift */; }; - 0A1B2C3D4E5F678901234509 /* RatingsReviews/AppStorefrontRatingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F67890123450A /* RatingsReviews/AppStorefrontRatingService.swift */; }; + 0A1B2C3D4E5F678901234509 /* AppStorefrontRatingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F67890123450A /* AppStorefrontRatingService.swift */; }; 0A1B2C3D4E5F67890123450D /* AppStorefrontRatingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F67890123450E /* AppStorefrontRatingServiceTests.swift */; }; 0A1B2C3D4E5F67890123450F /* AppFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F678901234510 /* AppFolder.swift */; }; 0A1B2C3D4E5F678901234511 /* AppStorefrontReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F678901234512 /* AppStorefrontReview.swift */; }; - 0A1B2C3D4E5F678901234513 /* RatingsReviews/AppStorefrontReviewService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F678901234514 /* RatingsReviews/AppStorefrontReviewService.swift */; }; - 0A5C00000000000000000001 /* AppStoreConnect/AppStoreConnectCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000002 /* AppStoreConnect/AppStoreConnectCredentials.swift */; }; - 0A5C00000000000000000003 /* AppStoreConnect/AppStoreConnectReviewService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000004 /* AppStoreConnect/AppStoreConnectReviewService.swift */; }; + 0A1B2C3D4E5F678901234513 /* AppStorefrontReviewService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F678901234514 /* AppStorefrontReviewService.swift */; }; + 0A5C00000000000000000001 /* AppStoreConnectCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000002 /* AppStoreConnectCredentials.swift */; }; + 0A5C00000000000000000003 /* AppStoreConnectReviewService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000004 /* AppStoreConnectReviewService.swift */; }; 0A5C00000000000000000005 /* AppStoreConnectReviewServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000006 /* AppStoreConnectReviewServiceTests.swift */; }; 0A5C00000000000000000007 /* AppStoreWebMetadataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000008 /* AppStoreWebMetadataProviderTests.swift */; }; - 0B50D676CD4C516507F3F947 /* SearchRanking/AppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */; }; + 0B50D676CD4C516507F3F947 /* AppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA05C59C087130E05FF3D085 /* AppResolver.swift */; }; 0EBC5115A0746B143811F971 /* OpenASOError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9838D42ED1A3EA1E6DDDD68C /* OpenASOError.swift */; }; 0FCA11A424784491606B13B7 /* MockHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4980369A7326B82ED173C90B /* MockHTTPClient.swift */; }; - 16AD27C075641058791E8F50 /* Networking/HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F922DEEB1FCD8E26B1A64A /* Networking/HTTPClient.swift */; }; + 16AD27C075641058791E8F50 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F922DEEB1FCD8E26B1A64A /* HTTPClient.swift */; }; 1E6D6CC10D4806BE5ECFC851 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B0B017D61A316FA58E2039 /* RootView.swift */; }; 1E6D6CC10D4806BE5ECFC852 /* RootSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B0B017D61A316FA58E2040 /* RootSidebarView.swift */; }; - 33B2BE83B4CFC8970D7A3482 /* SearchRanking/RankingRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */; }; + 33B2BE83B4CFC8970D7A3482 /* RankingRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D4B2C87FDED9CFCFA50D7D /* RankingRefreshCoordinator.swift */; }; 3A3B766B1E13C1E131179C31 /* DefaultAppResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16D1F1F736DDAB911B87E77 /* DefaultAppResolverTests.swift */; }; 3FBAFA6ACAF90A7959DC4053 /* RankingRefreshCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */; }; 43AE454C23461BBBC44D01DA /* RankingMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF42BE79CA0AE7107E410C7E /* RankingMatcherTests.swift */; }; - 453E1E4BE015A629E5B3F7B5 /* Storefront/StorefrontCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399F08A360281DA901E4457 /* Storefront/StorefrontCatalog.swift */; }; + 453E1E4BE015A629E5B3F7B5 /* StorefrontCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399F08A360281DA901E4457 /* StorefrontCatalog.swift */; }; 4D4350544553545300000002 /* OpenASOMCPServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4350544553545300000001 /* OpenASOMCPServiceTests.swift */; }; 4D4350545345525600000002 /* OpenASOMCPServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4350545345525600000001 /* OpenASOMCPServerTests.swift */; }; 4D4350545345525600000003 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = C0DEFACE0000000000000109 /* MCP */; }; 52A1A64F1D49D89D9B5DD204 /* AppPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC65F472D8AF2FC4DE4E8BD8 /* AppPlatform.swift */; }; 59E573ACDBBB76A905B92574 /* AddKeywordsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87EC021EFE3EDFB1445FFA7 /* AddKeywordsSheet.swift */; }; - 5B3ACA5345C1963D7B861055 /* SearchRanking/SearchRankingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F48387102DE88B407695C3 /* SearchRanking/SearchRankingProvider.swift */; }; - 5FAADABB0DA1EEDB3699E66C /* SearchRanking/RankingMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035877A6E7443B32A3980E39 /* SearchRanking/RankingMatcher.swift */; }; - 6C612150F69149F0A3F988EE /* Credentials/KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5530D41B3742698F02134C /* Credentials/KeychainService.swift */; }; + 5B3ACA5345C1963D7B861055 /* SearchRankingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F48387102DE88B407695C3 /* SearchRankingProvider.swift */; }; + 5FAADABB0DA1EEDB3699E66C /* RankingMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035877A6E7443B32A3980E39 /* RankingMatcher.swift */; }; + 6C612150F69149F0A3F988EE /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5530D41B3742698F02134C /* KeychainService.swift */; }; 766FE01C27334F5387A7E797 /* PreviewAppDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB55596DB73489FB40D836C /* PreviewAppDependencies.swift */; }; + 866130558E1E4EDCF752F629 /* GlobalKeywordSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D98EE8604D3F4B37E110792 /* GlobalKeywordSync.swift */; }; 8BFC241AE4FEFAF749D3BE5A /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D89E788AED9CC1E4FD78B3D /* AppServices.swift */; }; 8ED05A47F845B42089077D3F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8DD5ED26436229FF7472E342 /* Assets.xcassets */; }; 91029AB42FAE58B1001431EE /* AppIconDev.icon in Resources */ = {isa = PBXBuildFile; fileRef = 91029AB32FAE58B1001431EE /* AppIconDev.icon */; }; 911101D62F9C3562004DFA8A /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 911101D52F9C3562004DFA8A /* AppIcon.icon */; }; - 93C1A9E62DF448B89A6B5510 /* Resources/storefronts.json in Resources */ = {isa = PBXBuildFile; fileRef = 93C1A9E52DF448B89A6B5510 /* Resources/storefronts.json */; }; + 93C1A9E62DF448B89A6B5510 /* storefronts.json in Resources */ = {isa = PBXBuildFile; fileRef = 93C1A9E52DF448B89A6B5510 /* storefronts.json */; }; 99A4FE5166338C3329CF94EE /* TrackedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B088B3166AF98160A612326C /* TrackedApp.swift */; }; 9A6937276AF0E3CAEDA90C36 /* RankingSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B98B0F5A49E13B4633818A9 /* RankingSource.swift */; }; 9D53BDA1AB6F2313D0BFE2BA /* TrackedKeywordDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E539E28041AF31B5E3E321 /* TrackedKeywordDetailView.swift */; }; 9F18F6F8A4D64BE38FBED4A8 /* KeywordTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E564C613BC84E13B31CB08D /* KeywordTableView.swift */; }; A08A2D06F81C49F4A34FB388 /* KeywordTrendSparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08A2D05F81C49F4A34FB388 /* KeywordTrendSparklineView.swift */; }; - A0BEEF000000000000000001 /* Storefront/AppStoreWebMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1107 /* Storefront/AppStoreWebMetadataProvider.swift */; }; - A0BEEF000000000000000010 /* Storefront/ScreenshotDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */; }; - A0BEEF000000000000000011 /* Storefront/ScreenshotDownloadProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000013 /* Storefront/ScreenshotDownloadProgressStore.swift */; }; + A0BEEF000000000000000001 /* AppStoreWebMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1107 /* AppStoreWebMetadataProvider.swift */; }; + A0BEEF000000000000000010 /* ScreenshotDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000012 /* ScreenshotDownloadService.swift */; }; + A0BEEF000000000000000011 /* ScreenshotDownloadProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000013 /* ScreenshotDownloadProgressStore.swift */; }; A0BEEF000000000000000014 /* ScreenshotDownloadServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000015 /* ScreenshotDownloadServiceTests.swift */; }; A0D71B4C0000000000000004 /* AppDetailToolbarViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000001 /* AppDetailToolbarViews.swift */; }; A0D71B4C0000000000000006 /* AppRatingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000003 /* AppRatingsView.swift */; }; @@ -79,8 +80,8 @@ A0D71B4C000000000000000A /* FilterRangeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000008 /* FilterRangeSlider.swift */; }; A0D71B4C000000000000000C /* AppKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C000000000000000B /* AppKeywordsView.swift */; }; A0D71B4C000000000000000F /* KeywordInsightsTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000010 /* KeywordInsightsTypes.swift */; }; - A0D71B4C0000000000000011 /* SearchRanking/KeywordInsightsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000012 /* SearchRanking/KeywordInsightsService.swift */; }; - A0D71B4C0000000000000013 /* Persistence/DailyRefreshScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000014 /* Persistence/DailyRefreshScheduler.swift */; }; + A0D71B4C0000000000000011 /* KeywordInsightsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000012 /* KeywordInsightsService.swift */; }; + A0D71B4C0000000000000013 /* DailyRefreshScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000014 /* DailyRefreshScheduler.swift */; }; A0D71B4C0000000000000015 /* KeywordVisibilitySummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000016 /* KeywordVisibilitySummaryView.swift */; }; A0D71B4C0000000000000017 /* KeywordRankingChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C0000000000000018 /* KeywordRankingChartView.swift */; }; A0D71B4C0000000000000019 /* AppDetailPreviewFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D71B4C000000000000001A /* AppDetailPreviewFixtures.swift */; }; @@ -92,8 +93,8 @@ A20000000000000000000004 /* KeywordTableSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* KeywordTableSupport.swift */; }; A20000000000000000000005 /* KeywordTablePreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* KeywordTablePreviews.swift */; }; A20000000000000000000006 /* KeywordRankingResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* KeywordRankingResultRow.swift */; }; - A30000000000000000000001 /* Persistence/BackgroundModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000002 /* Persistence/BackgroundModelStore.swift */; }; - A30000000000000000000003 /* Persistence/ModelContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000004 /* Persistence/ModelContainerFactory.swift */; }; + A30000000000000000000001 /* BackgroundModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000002 /* BackgroundModelStore.swift */; }; + A30000000000000000000003 /* ModelContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000004 /* ModelContainerFactory.swift */; }; A301EA7838159D6EE3E53FD0 /* ITunesSearchFallbackProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6BDA000FFE3FC055867E78 /* ITunesSearchFallbackProviderTests.swift */; }; A40000000000000000000001 /* RatingsDashboardSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000011 /* RatingsDashboardSupport.swift */; }; A40000000000000000000002 /* RatingsSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000012 /* RatingsSidebar.swift */; }; @@ -106,21 +107,21 @@ A40000000000000000000009 /* ReviewFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000019 /* ReviewFilters.swift */; }; A4000000000000000000000A /* AppPricingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001A /* AppPricingView.swift */; }; A4000000000000000000000B /* AppPricingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001B /* AppPricingSnapshot.swift */; }; - A4000000000000000000000C /* AppPricingPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */; }; + A4000000000000000000000C /* AppPricingPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001C /* AppPricingPersistence.swift */; }; A4000000000000000000000D /* AppPricingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001B /* AppPricingSnapshot.swift */; }; A50000000000000000000001 /* PaginatedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50000000000000000000002 /* PaginatedList.swift */; }; A60000000000000000000001 /* AIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60000000000000000000002 /* AIServiceTests.swift */; }; A60000000000000000000003 /* ReviewTranslationIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */; }; - A70000000000000000000001 /* RatingsReviews/ReviewLanguageDetectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70000000000000000000002 /* RatingsReviews/ReviewLanguageDetectionService.swift */; }; - A70100000000000000000001 /* RatingsReviews/AppStorefrontRatingTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000002 /* RatingsReviews/AppStorefrontRatingTypes.swift */; }; - A70100000000000000000003 /* RatingsReviews/AppStorefrontRatingParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000004 /* RatingsReviews/AppStorefrontRatingParser.swift */; }; + A70000000000000000000001 /* ReviewLanguageDetectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70000000000000000000002 /* ReviewLanguageDetectionService.swift */; }; + A70100000000000000000001 /* AppStorefrontRatingTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000002 /* AppStorefrontRatingTypes.swift */; }; + A70100000000000000000003 /* AppStorefrontRatingParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000004 /* AppStorefrontRatingParser.swift */; }; A80000000000000000000001 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80000000000000000000002 /* AnalyticsService.swift */; }; A80000000000000000000003 /* AnalyticsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80000000000000000000004 /* AnalyticsServiceTests.swift */; }; A80000000000000000000005 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000006 /* PostHog */; }; - A90000000000000000000001 /* Updates/SparkleUpdaterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90000000000000000000002 /* Updates/SparkleUpdaterController.swift */; }; + A90000000000000000000001 /* SparkleUpdaterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90000000000000000000002 /* SparkleUpdaterController.swift */; }; A90000000000000000000005 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A90000000000000000000006 /* Sparkle */; }; AA0000000000000000000002 /* AppleAdsConnectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000001 /* AppleAdsConnectionStateTests.swift */; }; - AB171C45C65C542EFCD3CEBD /* SearchRanking/ITunesSearchFallbackProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB53B31A2B107CEE141F744 /* SearchRanking/ITunesSearchFallbackProvider.swift */; }; + AB171C45C65C542EFCD3CEBD /* ITunesSearchFallbackProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB53B31A2B107CEE141F744 /* ITunesSearchFallbackProvider.swift */; }; BEEF00000000000000000002 /* KeywordQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000001 /* KeywordQuery.swift */; }; BEEF00000000000000000006 /* StoreApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000005 /* StoreApp.swift */; }; BEEF00000000000000000008 /* KeywordDailyMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000007 /* KeywordDailyMetric.swift */; }; @@ -139,24 +140,24 @@ C0DEC0DE0000000000000008 /* Tooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE0000000000000007 /* Tooltip.swift */; }; C0DEC0DE000000000000000A /* TertiaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE0000000000000009 /* TertiaryActionButton.swift */; }; C0DEC0DE0000000000000012 /* MCPServerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE0000000000000011 /* MCPServerSheet.swift */; }; - C0DEFACE0000000000000102 /* MCP/OpenASOMCPDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000101 /* MCP/OpenASOMCPDTOs.swift */; }; - C0DEFACE0000000000000104 /* MCP/OpenASOMCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000103 /* MCP/OpenASOMCPService.swift */; }; - C0DEFACE0000000000000106 /* MCP/OpenASOMCPValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000105 /* MCP/OpenASOMCPValidation.swift */; }; - C0DEFACE0000000000000108 /* MCP/OpenASOMCPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000107 /* MCP/OpenASOMCPServer.swift */; }; + C0DEFACE0000000000000102 /* OpenASOMCPDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000101 /* OpenASOMCPDTOs.swift */; }; + C0DEFACE0000000000000104 /* OpenASOMCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000103 /* OpenASOMCPService.swift */; }; + C0DEFACE0000000000000106 /* OpenASOMCPValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000105 /* OpenASOMCPValidation.swift */; }; + C0DEFACE0000000000000108 /* OpenASOMCPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000107 /* OpenASOMCPServer.swift */; }; C0DEFACE000000000000010A /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = C0DEFACE0000000000000109 /* MCP */; }; - C0DEFACE000000000000010E /* MCP/OpenASOMCPRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010D /* MCP/OpenASOMCPRuntime.swift */; }; - C0DEFACE0000000000000110 /* MCP/OpenASOMCPServerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010F /* MCP/OpenASOMCPServerController.swift */; }; + C0DEFACE000000000000010E /* OpenASOMCPRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010D /* OpenASOMCPRuntime.swift */; }; + C0DEFACE0000000000000110 /* OpenASOMCPServerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010F /* OpenASOMCPServerController.swift */; }; C15A00000000000000000009 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = C0DEFACE0000000000000109 /* MCP */; }; - C15A0000000000000000000A /* MCP/OpenASOMCPMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15A00000000000000000001 /* MCP/OpenASOMCPMain.swift */; }; - C15A00000000000000000101 /* MCP/OpenASOMCPDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000101 /* MCP/OpenASOMCPDTOs.swift */; }; - C15A00000000000000000102 /* MCP/OpenASOMCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000103 /* MCP/OpenASOMCPService.swift */; }; - C15A00000000000000000103 /* MCP/OpenASOMCPValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000105 /* MCP/OpenASOMCPValidation.swift */; }; - C15A00000000000000000104 /* MCP/OpenASOMCPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000107 /* MCP/OpenASOMCPServer.swift */; }; - C15A00000000000000000105 /* MCP/OpenASOMCPRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010D /* MCP/OpenASOMCPRuntime.swift */; }; + C15A0000000000000000000A /* OpenASOMCPMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15A00000000000000000001 /* OpenASOMCPMain.swift */; }; + C15A00000000000000000101 /* OpenASOMCPDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000101 /* OpenASOMCPDTOs.swift */; }; + C15A00000000000000000102 /* OpenASOMCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000103 /* OpenASOMCPService.swift */; }; + C15A00000000000000000103 /* OpenASOMCPValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000105 /* OpenASOMCPValidation.swift */; }; + C15A00000000000000000104 /* OpenASOMCPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE0000000000000107 /* OpenASOMCPServer.swift */; }; + C15A00000000000000000105 /* OpenASOMCPRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFACE000000000000010D /* OpenASOMCPRuntime.swift */; }; C15A00000000000000000106 /* AppNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* AppNamespace.swift */; }; C15A00000000000000000107 /* OpenASOSchemaV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* OpenASOSchemaV1.swift */; }; - C15A00000000000000000108 /* Persistence/BackgroundModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000002 /* Persistence/BackgroundModelStore.swift */; }; - C15A00000000000000000109 /* Persistence/ModelContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000004 /* Persistence/ModelContainerFactory.swift */; }; + C15A00000000000000000108 /* BackgroundModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000002 /* BackgroundModelStore.swift */; }; + C15A00000000000000000109 /* ModelContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30000000000000000000004 /* ModelContainerFactory.swift */; }; C15A0000000000000000010A /* OpenASOError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9838D42ED1A3EA1E6DDDD68C /* OpenASOError.swift */; }; C15A0000000000000000010B /* AppPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC65F472D8AF2FC4DE4E8BD8 /* AppPlatform.swift */; }; C15A0000000000000000010C /* RankingSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B98B0F5A49E13B4633818A9 /* RankingSource.swift */; }; @@ -177,23 +178,24 @@ C15A0000000000000000011B /* TrackedKeywordDailyRanking.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000013 /* TrackedKeywordDailyRanking.swift */; }; C15A0000000000000000011C /* TrackedKeywordRankedResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000015 /* TrackedKeywordRankedResult.swift */; }; C15A0000000000000000011D /* TrackedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B088B3166AF98160A612326C /* TrackedApp.swift */; }; - C15A0000000000000000011E /* Networking/HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F922DEEB1FCD8E26B1A64A /* Networking/HTTPClient.swift */; }; - C15A0000000000000000011F /* SearchRanking/AppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */; }; - C15A00000000000000000120 /* SearchRanking/SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */; }; - C15A00000000000000000121 /* SearchRanking/DefaultAppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */; }; - C15A00000000000000000122 /* Storefront/AppCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1105 /* Storefront/AppCatalogService.swift */; }; - C15A00000000000000000123 /* Storefront/AppStoreWebMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1107 /* Storefront/AppStoreWebMetadataProvider.swift */; }; - C15A00000000000000000124 /* Storefront/ScreenshotDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */; }; - C15A00000000000000000125 /* RatingsReviews/AppStorefrontRatingTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000002 /* RatingsReviews/AppStorefrontRatingTypes.swift */; }; - C15A00000000000000000126 /* RatingsReviews/AppStorefrontRatingParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000004 /* RatingsReviews/AppStorefrontRatingParser.swift */; }; + C15A0000000000000000011E /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F922DEEB1FCD8E26B1A64A /* HTTPClient.swift */; }; + C15A0000000000000000011F /* AppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA05C59C087130E05FF3D085 /* AppResolver.swift */; }; + C15A00000000000000000120 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1EE58C790084CF7A83CA8A /* SearchModels.swift */; }; + C15A00000000000000000121 /* DefaultAppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465129A02F247ED683558D8B /* DefaultAppResolver.swift */; }; + C15A00000000000000000122 /* AppCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1105 /* AppCatalogService.swift */; }; + C15A00000000000000000123 /* AppStoreWebMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1107 /* AppStoreWebMetadataProvider.swift */; }; + C15A00000000000000000124 /* ScreenshotDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BEEF000000000000000012 /* ScreenshotDownloadService.swift */; }; + C15A00000000000000000125 /* AppStorefrontRatingTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000002 /* AppStorefrontRatingTypes.swift */; }; + C15A00000000000000000126 /* AppStorefrontRatingParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70100000000000000000004 /* AppStorefrontRatingParser.swift */; }; C15A00000000000000000127 /* KeywordMetricsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */; }; - C15A00000000000000000128 /* Storefront/StorefrontCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399F08A360281DA901E4457 /* Storefront/StorefrontCatalog.swift */; }; + C15A00000000000000000128 /* StorefrontCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399F08A360281DA901E4457 /* StorefrontCatalog.swift */; }; + C41E10A81C85C97B31AC30BC /* KeywordMarketInsightsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6064A32D106640283CF33D /* KeywordMarketInsightsSheet.swift */; }; C5A400000000000000000001 /* CSVFormats.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A400000000000000000002 /* CSVFormats.swift */; }; C5A400000000000000000003 /* CSVFormatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A400000000000000000004 /* CSVFormatsTests.swift */; }; - C70260E8797CC353D9AD1B3A /* SearchRanking/SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */; }; + C70260E8797CC353D9AD1B3A /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1EE58C790084CF7A83CA8A /* SearchModels.swift */; }; CB1733C5A8778748B70BA4FD /* AppDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F7AF5B5856F0183D027B4 /* AppDetailView.swift */; }; D30000000000000000000002 /* AppNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* AppNamespace.swift */; }; - D6807507E1AF0AB52CB2C7E2 /* SearchRanking/DefaultAppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */; }; + D6807507E1AF0AB52CB2C7E2 /* DefaultAppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465129A02F247ED683558D8B /* DefaultAppResolver.swift */; }; EF3B2BA43807572789A250A7 /* AddAppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCBBF8A3FABA145BF5122ED /* AddAppSheet.swift */; }; FC2B65186E0CA31841938E40 /* Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBEF6240F66A5F359B09D41 /* Storefront.swift */; }; FE86E03B40393EF38C032A45 /* TrackedAppKeywordIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63CBBCCA79CA32CCB5BFE4F /* TrackedAppKeywordIdentityTests.swift */; }; @@ -211,52 +213,53 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 035877A6E7443B32A3980E39 /* SearchRanking/RankingMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingMatcher.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1105 /* Storefront/AppCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppCatalogService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1106 /* Storefront/AppIconStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppIconStore.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1107 /* Storefront/AppStoreWebMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppStoreWebMetadataProvider.swift; sourceTree = ""; }; + 035877A6E7443B32A3980E39 /* RankingMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingMatcher.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1105 /* AppCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppCatalogService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1106 /* AppIconStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppIconStore.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1107 /* AppStoreWebMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/AppStoreWebMetadataProvider.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconView.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsCredentials.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A110C /* AppleAdsCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsCredentials.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A110D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleSearchAdsJWT.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A110F /* AppleSearchAdsJWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleSearchAdsJWT.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordMetricsSource.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/KeywordMetricsService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordSuggestionService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsWebSession.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1126 /* script/apple_ads_web_session.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = script/apple_ads_web_session.js; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1115 /* KeywordMetricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/KeywordMetricsService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1116 /* KeywordSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordSuggestionService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1124 /* AppleAdsWebSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsWebSession.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1126 /* apple_ads_web_session.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = script/apple_ads_web_session.js; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1128 /* KeywordMetricsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordMetricsServiceTests.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1130 /* AI/AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/AIService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1131 /* AI/AITranslationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/AITranslationService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1132 /* AI/FoundationModelsAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/FoundationModelsAIService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1130 /* AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/AIService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1131 /* AITranslationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/AITranslationService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1132 /* FoundationModelsAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI/FoundationModelsAIService.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1137 /* CodableAppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableAppStorage.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1139 /* AppleAdsSettingsConnectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsSettingsConnectionState.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A113B /* AppleAdsSettingsStatusRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsSettingsStatusRows.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A113D /* SettingsViewPreviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPreviews.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1141 /* AppDetail/AppDetailRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppDetailRefreshService.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A1143 /* AppDetail/AppRefreshProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppRefreshProgressStore.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1141 /* AppDetailRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppDetailRefreshService.swift; sourceTree = ""; }; + 04E8C4E18F1A4D519C0A1143 /* AppRefreshProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppRefreshProgressStore.swift; sourceTree = ""; }; 0A1B2C3D4E5F678901234506 /* AppKeywordStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeywordStats.swift; sourceTree = ""; }; - 0A1B2C3D4E5F67890123450A /* RatingsReviews/AppStorefrontRatingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingService.swift; sourceTree = ""; }; + 0A1B2C3D4E5F67890123450A /* AppStorefrontRatingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingService.swift; sourceTree = ""; }; 0A1B2C3D4E5F67890123450E /* AppStorefrontRatingServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorefrontRatingServiceTests.swift; sourceTree = ""; }; 0A1B2C3D4E5F678901234510 /* AppFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFolder.swift; sourceTree = ""; }; 0A1B2C3D4E5F678901234512 /* AppStorefrontReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorefrontReview.swift; sourceTree = ""; }; - 0A1B2C3D4E5F678901234514 /* RatingsReviews/AppStorefrontReviewService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontReviewService.swift; sourceTree = ""; }; - 0A5C00000000000000000002 /* AppStoreConnect/AppStoreConnectCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnect/AppStoreConnectCredentials.swift; sourceTree = ""; }; - 0A5C00000000000000000004 /* AppStoreConnect/AppStoreConnectReviewService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnect/AppStoreConnectReviewService.swift; sourceTree = ""; }; + 0A1B2C3D4E5F678901234514 /* AppStorefrontReviewService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontReviewService.swift; sourceTree = ""; }; + 0A5C00000000000000000002 /* AppStoreConnectCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnect/AppStoreConnectCredentials.swift; sourceTree = ""; }; + 0A5C00000000000000000004 /* AppStoreConnectReviewService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnect/AppStoreConnectReviewService.swift; sourceTree = ""; }; 0A5C00000000000000000006 /* AppStoreConnectReviewServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnectReviewServiceTests.swift; sourceTree = ""; }; 0A5C00000000000000000008 /* AppStoreWebMetadataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreWebMetadataProviderTests.swift; sourceTree = ""; }; 0CC1A541B8A64AB1A36B50E5 /* AppServicesDependencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesDependencyTests.swift; sourceTree = ""; }; - 1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchModels.swift; sourceTree = ""; }; - 26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingRefreshCoordinator.swift; sourceTree = ""; }; + 1C1EE58C790084CF7A83CA8A /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchModels.swift; sourceTree = ""; }; + 26D4B2C87FDED9CFCFA50D7D /* RankingRefreshCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingRefreshCoordinator.swift; sourceTree = ""; }; 2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingRefreshCoordinatorTests.swift; sourceTree = ""; }; 2EB55596DB73489FB40D836C /* PreviewAppDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppDependencies.swift; sourceTree = ""; }; 378F7AF5B5856F0183D027B4 /* AppDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailView.swift; sourceTree = ""; }; - 465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/DefaultAppResolver.swift; sourceTree = ""; }; + 465129A02F247ED683558D8B /* DefaultAppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/DefaultAppResolver.swift; sourceTree = ""; }; 4980369A7326B82ED173C90B /* MockHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockHTTPClient.swift; sourceTree = ""; }; 4D4350544553545300000001 /* OpenASOMCPServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenASOMCPServiceTests.swift; sourceTree = ""; }; 4D4350545345525600000001 /* OpenASOMCPServerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenASOMCPServerTests.swift; sourceTree = ""; }; 521B4EBEC0EC5B7902728E9E /* OpenASO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenASO.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5C5530D41B3742698F02134C /* Credentials/KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials/KeychainService.swift; sourceTree = ""; }; + 5C5530D41B3742698F02134C /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials/KeychainService.swift; sourceTree = ""; }; 5D89E788AED9CC1E4FD78B3D /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = ""; }; + 6D98EE8604D3F4B37E110792 /* GlobalKeywordSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GlobalKeywordSync.swift; path = AppDetail/GlobalKeywordSync.swift; sourceTree = ""; }; 7B98B0F5A49E13B4633818A9 /* RankingSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingSource.swift; sourceTree = ""; }; 7E564C613BC84E13B31CB08D /* KeywordTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTableView.swift; sourceTree = ""; }; 88F93BD35C340B950DE2A5A8 /* OpenASOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenASOApp.swift; sourceTree = ""; }; @@ -265,11 +268,11 @@ 91029AB32FAE58B1001431EE /* AppIconDev.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIconDev.icon; sourceTree = ""; }; 911101D52F9C3562004DFA8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 92798A850EAA13FEE5812E4E /* OpenASOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenASOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 93C1A9E52DF448B89A6B5510 /* Resources/storefronts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/storefronts.json; sourceTree = ""; }; + 93C1A9E52DF448B89A6B5510 /* storefronts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/storefronts.json; sourceTree = ""; }; 9838D42ED1A3EA1E6DDDD68C /* OpenASOError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenASOError.swift; sourceTree = ""; }; A08A2D05F81C49F4A34FB388 /* KeywordTrendSparklineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTrendSparklineView.swift; sourceTree = ""; }; - A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/ScreenshotDownloadService.swift; sourceTree = ""; }; - A0BEEF000000000000000013 /* Storefront/ScreenshotDownloadProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/ScreenshotDownloadProgressStore.swift; sourceTree = ""; }; + A0BEEF000000000000000012 /* ScreenshotDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/ScreenshotDownloadService.swift; sourceTree = ""; }; + A0BEEF000000000000000013 /* ScreenshotDownloadProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/ScreenshotDownloadProgressStore.swift; sourceTree = ""; }; A0BEEF000000000000000015 /* ScreenshotDownloadServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotDownloadServiceTests.swift; sourceTree = ""; }; A0D71B4C0000000000000001 /* AppDetailToolbarViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailToolbarViews.swift; sourceTree = ""; }; A0D71B4C0000000000000003 /* AppRatingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRatingsView.swift; sourceTree = ""; }; @@ -277,8 +280,8 @@ A0D71B4C0000000000000008 /* FilterRangeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRangeSlider.swift; sourceTree = ""; }; A0D71B4C000000000000000B /* AppKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeywordsView.swift; sourceTree = ""; }; A0D71B4C0000000000000010 /* KeywordInsightsTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordInsightsTypes.swift; sourceTree = ""; }; - A0D71B4C0000000000000012 /* SearchRanking/KeywordInsightsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordInsightsService.swift; sourceTree = ""; }; - A0D71B4C0000000000000014 /* Persistence/DailyRefreshScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/DailyRefreshScheduler.swift; sourceTree = ""; }; + A0D71B4C0000000000000012 /* KeywordInsightsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordInsightsService.swift; sourceTree = ""; }; + A0D71B4C0000000000000014 /* DailyRefreshScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/DailyRefreshScheduler.swift; sourceTree = ""; }; A0D71B4C0000000000000016 /* KeywordVisibilitySummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordVisibilitySummaryView.swift; sourceTree = ""; }; A0D71B4C0000000000000018 /* KeywordRankingChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordRankingChartView.swift; sourceTree = ""; }; A0D71B4C000000000000001A /* AppDetailPreviewFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailPreviewFixtures.swift; sourceTree = ""; }; @@ -290,8 +293,8 @@ A20000000000000000000014 /* KeywordTableSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTableSupport.swift; sourceTree = ""; }; A20000000000000000000015 /* KeywordTablePreviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTablePreviews.swift; sourceTree = ""; }; A20000000000000000000016 /* KeywordRankingResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordRankingResultRow.swift; sourceTree = ""; }; - A30000000000000000000002 /* Persistence/BackgroundModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/BackgroundModelStore.swift; sourceTree = ""; }; - A30000000000000000000004 /* Persistence/ModelContainerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/ModelContainerFactory.swift; sourceTree = ""; }; + A30000000000000000000002 /* BackgroundModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/BackgroundModelStore.swift; sourceTree = ""; }; + A30000000000000000000004 /* ModelContainerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/ModelContainerFactory.swift; sourceTree = ""; }; A40000000000000000000011 /* RatingsDashboardSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsDashboardSupport.swift; sourceTree = ""; }; A40000000000000000000012 /* RatingsSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsSidebar.swift; sourceTree = ""; }; A40000000000000000000013 /* RatingsDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsDetail.swift; sourceTree = ""; }; @@ -303,24 +306,24 @@ A40000000000000000000019 /* ReviewFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewFilters.swift; sourceTree = ""; }; A4000000000000000000001A /* AppPricingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPricingView.swift; sourceTree = ""; }; A4000000000000000000001B /* AppPricingSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPricingSnapshot.swift; sourceTree = ""; }; - A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppPricingPersistence.swift; sourceTree = ""; }; + A4000000000000000000001C /* AppPricingPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetail/AppPricingPersistence.swift; sourceTree = ""; }; A50000000000000000000002 /* PaginatedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedList.swift; sourceTree = ""; }; A60000000000000000000002 /* AIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewTranslationIntegrationTests.swift; sourceTree = ""; }; A63CBBCCA79CA32CCB5BFE4F /* TrackedAppKeywordIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackedAppKeywordIdentityTests.swift; sourceTree = ""; }; - A70000000000000000000002 /* RatingsReviews/ReviewLanguageDetectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/ReviewLanguageDetectionService.swift; sourceTree = ""; }; - A70100000000000000000002 /* RatingsReviews/AppStorefrontRatingTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingTypes.swift; sourceTree = ""; }; - A70100000000000000000004 /* RatingsReviews/AppStorefrontRatingParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingParser.swift; sourceTree = ""; }; + A70000000000000000000002 /* ReviewLanguageDetectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/ReviewLanguageDetectionService.swift; sourceTree = ""; }; + A70100000000000000000002 /* AppStorefrontRatingTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingTypes.swift; sourceTree = ""; }; + A70100000000000000000004 /* AppStorefrontRatingParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsReviews/AppStorefrontRatingParser.swift; sourceTree = ""; }; A7E539E28041AF31B5E3E321 /* TrackedKeywordDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackedKeywordDetailView.swift; sourceTree = ""; }; A80000000000000000000002 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; A80000000000000000000004 /* AnalyticsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServiceTests.swift; sourceTree = ""; }; - A90000000000000000000002 /* Updates/SparkleUpdaterController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updates/SparkleUpdaterController.swift; sourceTree = ""; }; + A90000000000000000000002 /* SparkleUpdaterController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updates/SparkleUpdaterController.swift; sourceTree = ""; }; A90000000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AA0000000000000000000001 /* AppleAdsConnectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAdsConnectionStateTests.swift; sourceTree = ""; }; AF42BE79CA0AE7107E410C7E /* RankingMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingMatcherTests.swift; sourceTree = ""; }; B088B3166AF98160A612326C /* TrackedApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackedApp.swift; sourceTree = ""; }; B16D1F1F736DDAB911B87E77 /* DefaultAppResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAppResolverTests.swift; sourceTree = ""; }; - B399F08A360281DA901E4457 /* Storefront/StorefrontCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/StorefrontCatalog.swift; sourceTree = ""; }; + B399F08A360281DA901E4457 /* StorefrontCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront/StorefrontCatalog.swift; sourceTree = ""; }; B8B0B017D61A316FA58E2039 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; B8B0B017D61A316FA58E2040 /* RootSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSidebarView.swift; sourceTree = ""; }; BC65F472D8AF2FC4DE4E8BD8 /* AppPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPlatform.swift; sourceTree = ""; }; @@ -343,24 +346,25 @@ C0DEC0DE0000000000000007 /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = ""; }; C0DEC0DE0000000000000009 /* TertiaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TertiaryActionButton.swift; sourceTree = ""; }; C0DEC0DE0000000000000011 /* MCPServerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPServerSheet.swift; sourceTree = ""; }; - C0DEFACE0000000000000101 /* MCP/OpenASOMCPDTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPDTOs.swift; sourceTree = ""; }; - C0DEFACE0000000000000103 /* MCP/OpenASOMCPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPService.swift; sourceTree = ""; }; - C0DEFACE0000000000000105 /* MCP/OpenASOMCPValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPValidation.swift; sourceTree = ""; }; - C0DEFACE0000000000000107 /* MCP/OpenASOMCPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPServer.swift; sourceTree = ""; }; - C0DEFACE000000000000010D /* MCP/OpenASOMCPRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPRuntime.swift; sourceTree = ""; }; - C0DEFACE000000000000010F /* MCP/OpenASOMCPServerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPServerController.swift; sourceTree = ""; }; - C15A00000000000000000001 /* MCP/OpenASOMCPMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPMain.swift; sourceTree = ""; }; + C0DEFACE0000000000000101 /* OpenASOMCPDTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPDTOs.swift; sourceTree = ""; }; + C0DEFACE0000000000000103 /* OpenASOMCPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPService.swift; sourceTree = ""; }; + C0DEFACE0000000000000105 /* OpenASOMCPValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPValidation.swift; sourceTree = ""; }; + C0DEFACE0000000000000107 /* OpenASOMCPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPServer.swift; sourceTree = ""; }; + C0DEFACE000000000000010D /* OpenASOMCPRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPRuntime.swift; sourceTree = ""; }; + C0DEFACE000000000000010F /* OpenASOMCPServerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPServerController.swift; sourceTree = ""; }; + C15A00000000000000000001 /* OpenASOMCPMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/OpenASOMCPMain.swift; sourceTree = ""; }; C15A00000000000000000002 /* OpenASOMCP */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = OpenASOMCP; sourceTree = BUILT_PRODUCTS_DIR; }; C5A400000000000000000002 /* CSVFormats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFormats.swift; sourceTree = ""; }; C5A400000000000000000004 /* CSVFormatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFormatsTests.swift; sourceTree = ""; }; D30000000000000000000001 /* AppNamespace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNamespace.swift; sourceTree = ""; }; D30000000000000000000005 /* OpenASO.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenASO.entitlements; sourceTree = ""; }; - D8F922DEEB1FCD8E26B1A64A /* Networking/HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking/HTTPClient.swift; sourceTree = ""; }; - DEB53B31A2B107CEE141F744 /* SearchRanking/ITunesSearchFallbackProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/ITunesSearchFallbackProvider.swift; sourceTree = ""; }; + D8F922DEEB1FCD8E26B1A64A /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking/HTTPClient.swift; sourceTree = ""; }; + DEB53B31A2B107CEE141F744 /* ITunesSearchFallbackProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/ITunesSearchFallbackProvider.swift; sourceTree = ""; }; E87EC021EFE3EDFB1445FFA7 /* AddKeywordsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddKeywordsSheet.swift; sourceTree = ""; }; ECBEF6240F66A5F359B09D41 /* Storefront.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront.swift; sourceTree = ""; }; - F1F48387102DE88B407695C3 /* SearchRanking/SearchRankingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchRankingProvider.swift; sourceTree = ""; }; - FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/AppResolver.swift; sourceTree = ""; }; + F1F48387102DE88B407695C3 /* SearchRankingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchRankingProvider.swift; sourceTree = ""; }; + FA05C59C087130E05FF3D085 /* AppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/AppResolver.swift; sourceTree = ""; }; + FE6064A32D106640283CF33D /* KeywordMarketInsightsSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeywordMarketInsightsSheet.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -445,53 +449,54 @@ 54FB5EDBECF218FCB9C40ADB /* Services */ = { isa = PBXGroup; children = ( - 04E8C4E18F1A4D519C0A1130 /* AI/AIService.swift */, - 04E8C4E18F1A4D519C0A1131 /* AI/AITranslationService.swift */, + 04E8C4E18F1A4D519C0A1130 /* AIService.swift */, + 04E8C4E18F1A4D519C0A1131 /* AITranslationService.swift */, A80000000000000000000002 /* AnalyticsService.swift */, - 04E8C4E18F1A4D519C0A1141 /* AppDetail/AppDetailRefreshService.swift */, - A4000000000000000000001C /* AppDetail/AppPricingPersistence.swift */, - 04E8C4E18F1A4D519C0A1143 /* AppDetail/AppRefreshProgressStore.swift */, - 04E8C4E18F1A4D519C0A1105 /* Storefront/AppCatalogService.swift */, - 04E8C4E18F1A4D519C0A1107 /* Storefront/AppStoreWebMetadataProvider.swift */, - A0BEEF000000000000000013 /* Storefront/ScreenshotDownloadProgressStore.swift */, - A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */, - FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */, - 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */, - 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */, - 04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */, - 04E8C4E18F1A4D519C0A1106 /* Storefront/AppIconStore.swift */, - 0A5C00000000000000000002 /* AppStoreConnect/AppStoreConnectCredentials.swift */, - 0A5C00000000000000000004 /* AppStoreConnect/AppStoreConnectReviewService.swift */, - 0A1B2C3D4E5F67890123450A /* RatingsReviews/AppStorefrontRatingService.swift */, - A70100000000000000000004 /* RatingsReviews/AppStorefrontRatingParser.swift */, - A70100000000000000000002 /* RatingsReviews/AppStorefrontRatingTypes.swift */, - 0A1B2C3D4E5F678901234514 /* RatingsReviews/AppStorefrontReviewService.swift */, - A30000000000000000000002 /* Persistence/BackgroundModelStore.swift */, - A0D71B4C0000000000000014 /* Persistence/DailyRefreshScheduler.swift */, - 465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */, - 04E8C4E18F1A4D519C0A1132 /* AI/FoundationModelsAIService.swift */, - D8F922DEEB1FCD8E26B1A64A /* Networking/HTTPClient.swift */, - DEB53B31A2B107CEE141F744 /* SearchRanking/ITunesSearchFallbackProvider.swift */, - 5C5530D41B3742698F02134C /* Credentials/KeychainService.swift */, - C0DEFACE0000000000000101 /* MCP/OpenASOMCPDTOs.swift */, - C0DEFACE0000000000000103 /* MCP/OpenASOMCPService.swift */, - C0DEFACE0000000000000105 /* MCP/OpenASOMCPValidation.swift */, - C0DEFACE0000000000000107 /* MCP/OpenASOMCPServer.swift */, - C0DEFACE000000000000010D /* MCP/OpenASOMCPRuntime.swift */, - C0DEFACE000000000000010F /* MCP/OpenASOMCPServerController.swift */, - C15A00000000000000000001 /* MCP/OpenASOMCPMain.swift */, - A0D71B4C0000000000000012 /* SearchRanking/KeywordInsightsService.swift */, - 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */, - 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */, - A30000000000000000000004 /* Persistence/ModelContainerFactory.swift */, + 04E8C4E18F1A4D519C0A1141 /* AppDetailRefreshService.swift */, + A4000000000000000000001C /* AppPricingPersistence.swift */, + 04E8C4E18F1A4D519C0A1143 /* AppRefreshProgressStore.swift */, + 04E8C4E18F1A4D519C0A1105 /* AppCatalogService.swift */, + 04E8C4E18F1A4D519C0A1107 /* AppStoreWebMetadataProvider.swift */, + A0BEEF000000000000000013 /* ScreenshotDownloadProgressStore.swift */, + A0BEEF000000000000000012 /* ScreenshotDownloadService.swift */, + FA05C59C087130E05FF3D085 /* AppResolver.swift */, + 04E8C4E18F1A4D519C0A110C /* AppleAdsCredentials.swift */, + 04E8C4E18F1A4D519C0A110F /* AppleSearchAdsJWT.swift */, + 04E8C4E18F1A4D519C0A1124 /* AppleAdsWebSession.swift */, + 04E8C4E18F1A4D519C0A1106 /* AppIconStore.swift */, + 0A5C00000000000000000002 /* AppStoreConnectCredentials.swift */, + 0A5C00000000000000000004 /* AppStoreConnectReviewService.swift */, + 0A1B2C3D4E5F67890123450A /* AppStorefrontRatingService.swift */, + A70100000000000000000004 /* AppStorefrontRatingParser.swift */, + A70100000000000000000002 /* AppStorefrontRatingTypes.swift */, + 0A1B2C3D4E5F678901234514 /* AppStorefrontReviewService.swift */, + A30000000000000000000002 /* BackgroundModelStore.swift */, + A0D71B4C0000000000000014 /* DailyRefreshScheduler.swift */, + 465129A02F247ED683558D8B /* DefaultAppResolver.swift */, + 04E8C4E18F1A4D519C0A1132 /* FoundationModelsAIService.swift */, + D8F922DEEB1FCD8E26B1A64A /* HTTPClient.swift */, + DEB53B31A2B107CEE141F744 /* ITunesSearchFallbackProvider.swift */, + 5C5530D41B3742698F02134C /* KeychainService.swift */, + C0DEFACE0000000000000101 /* OpenASOMCPDTOs.swift */, + C0DEFACE0000000000000103 /* OpenASOMCPService.swift */, + C0DEFACE0000000000000105 /* OpenASOMCPValidation.swift */, + C0DEFACE0000000000000107 /* OpenASOMCPServer.swift */, + C0DEFACE000000000000010D /* OpenASOMCPRuntime.swift */, + C0DEFACE000000000000010F /* OpenASOMCPServerController.swift */, + C15A00000000000000000001 /* OpenASOMCPMain.swift */, + A0D71B4C0000000000000012 /* KeywordInsightsService.swift */, + 04E8C4E18F1A4D519C0A1115 /* KeywordMetricsService.swift */, + 04E8C4E18F1A4D519C0A1116 /* KeywordSuggestionService.swift */, + A30000000000000000000004 /* ModelContainerFactory.swift */, 9838D42ED1A3EA1E6DDDD68C /* OpenASOError.swift */, - 035877A6E7443B32A3980E39 /* SearchRanking/RankingMatcher.swift */, - 26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */, - A70000000000000000000002 /* RatingsReviews/ReviewLanguageDetectionService.swift */, - 1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */, - F1F48387102DE88B407695C3 /* SearchRanking/SearchRankingProvider.swift */, - A90000000000000000000002 /* Updates/SparkleUpdaterController.swift */, - B399F08A360281DA901E4457 /* Storefront/StorefrontCatalog.swift */, + 035877A6E7443B32A3980E39 /* RankingMatcher.swift */, + 26D4B2C87FDED9CFCFA50D7D /* RankingRefreshCoordinator.swift */, + A70000000000000000000002 /* ReviewLanguageDetectionService.swift */, + 1C1EE58C790084CF7A83CA8A /* SearchModels.swift */, + F1F48387102DE88B407695C3 /* SearchRankingProvider.swift */, + A90000000000000000000002 /* SparkleUpdaterController.swift */, + B399F08A360281DA901E4457 /* StorefrontCatalog.swift */, + 6D98EE8604D3F4B37E110792 /* GlobalKeywordSync.swift */, ); path = Services; sourceTree = ""; @@ -501,7 +506,7 @@ children = ( DFE67173E202723EBDF2BB72 /* OpenASO */, 1027BC248FF4568E8523C3A8 /* OpenASOTests */, - 04E8C4E18F1A4D519C0A1126 /* script/apple_ads_web_session.js */, + 04E8C4E18F1A4D519C0A1126 /* apple_ads_web_session.js */, A165F2EA1017914A012D5804 /* Products */, ); sourceTree = ""; @@ -602,6 +607,7 @@ A0D71B4C0000000000000010 /* KeywordInsightsTypes.swift */, C10000000000000000000003 /* Insights */, A20000000000000000000020 /* Table */, + FE6064A32D106640283CF33D /* KeywordMarketInsightsSheet.swift */, ); path = Keywords; sourceTree = ""; @@ -686,7 +692,7 @@ C5A400000000000000000005 /* Utilities */, A90000000000000000000003 /* Info.plist */, D30000000000000000000005 /* OpenASO.entitlements */, - 93C1A9E52DF448B89A6B5510 /* Resources/storefronts.json */, + 93C1A9E52DF448B89A6B5510 /* storefronts.json */, 911101D52F9C3562004DFA8A /* AppIcon.icon */, 91029AB32FAE58B1001431EE /* AppIconDev.icon */, ); @@ -782,6 +788,7 @@ A90000000000000000000007 /* XCRemoteSwiftPackageReference "Sparkle" */, C0DEFACE000000000000010B /* XCRemoteSwiftPackageReference "swift-sdk" */, ); + productRefGroup = A165F2EA1017914A012D5804 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -798,10 +805,10 @@ buildActionMask = 2147483647; files = ( 8ED05A47F845B42089077D3F /* Assets.xcassets in Resources */, - 04E8C4E18F1A4D519C0A1125 /* script/apple_ads_web_session.js in Resources */, + 04E8C4E18F1A4D519C0A1125 /* apple_ads_web_session.js in Resources */, 91029AB42FAE58B1001431EE /* AppIconDev.icon in Resources */, 911101D62F9C3562004DFA8A /* AppIcon.icon in Resources */, - 93C1A9E62DF448B89A6B5510 /* Resources/storefronts.json in Resources */, + 93C1A9E62DF448B89A6B5510 /* storefronts.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -839,52 +846,52 @@ files = ( EF3B2BA43807572789A250A7 /* AddAppSheet.swift in Sources */, 59E573ACDBBB76A905B92574 /* AddKeywordsSheet.swift in Sources */, - 04E8C4E18F1A4D519C0A1133 /* AI/AIService.swift in Sources */, - 04E8C4E18F1A4D519C0A1134 /* AI/AITranslationService.swift in Sources */, + 04E8C4E18F1A4D519C0A1133 /* AIService.swift in Sources */, + 04E8C4E18F1A4D519C0A1134 /* AITranslationService.swift in Sources */, A80000000000000000000001 /* AnalyticsService.swift in Sources */, - 04E8C4E18F1A4D519C0A1101 /* Storefront/AppCatalogService.swift in Sources */, - A0BEEF000000000000000011 /* Storefront/ScreenshotDownloadProgressStore.swift in Sources */, - A0BEEF000000000000000010 /* Storefront/ScreenshotDownloadService.swift in Sources */, + 04E8C4E18F1A4D519C0A1101 /* AppCatalogService.swift in Sources */, + A0BEEF000000000000000011 /* ScreenshotDownloadProgressStore.swift in Sources */, + A0BEEF000000000000000010 /* ScreenshotDownloadService.swift in Sources */, BEEF0000000000000000000E /* AppDailyRating.swift in Sources */, 0A1B2C3D4E5F678901234505 /* AppKeywordStats.swift in Sources */, - 04E8C4E18F1A4D519C0A1140 /* AppDetail/AppDetailRefreshService.swift in Sources */, - 04E8C4E18F1A4D519C0A1142 /* AppDetail/AppRefreshProgressStore.swift in Sources */, - 04E8C4E18F1A4D519C0A110A /* AppleAds/AppleAdsCredentials.swift in Sources */, + 04E8C4E18F1A4D519C0A1140 /* AppDetailRefreshService.swift in Sources */, + 04E8C4E18F1A4D519C0A1142 /* AppRefreshProgressStore.swift in Sources */, + 04E8C4E18F1A4D519C0A110A /* AppleAdsCredentials.swift in Sources */, 04E8C4E18F1A4D519C0A1138 /* AppleAdsSettingsConnectionState.swift in Sources */, 04E8C4E18F1A4D519C0A113C /* SettingsViewPreviews.swift in Sources */, D30000000000000000000002 /* AppNamespace.swift in Sources */, 04E8C4E18F1A4D519C0A113A /* AppleAdsSettingsStatusRows.swift in Sources */, 04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */, - 04E8C4E18F1A4D519C0A1123 /* AppleAds/AppleAdsWebSession.swift in Sources */, - 04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */, + 04E8C4E18F1A4D519C0A1123 /* AppleAdsWebSession.swift in Sources */, + 04E8C4E18F1A4D519C0A110E /* AppleSearchAdsJWT.swift in Sources */, A0D71B4C0000000000000009 /* AppDetailSupportTypes.swift in Sources */, A0D71B4C0000000000000019 /* AppDetailPreviewFixtures.swift in Sources */, A0D71B4C000000000000001B /* AppDetailCSVTransfer.swift in Sources */, A0D71B4C0000000000000004 /* AppDetailToolbarViews.swift in Sources */, CB1733C5A8778748B70BA4FD /* AppDetailView.swift in Sources */, A0D71B4C000000000000000A /* FilterRangeSlider.swift in Sources */, - 04E8C4E18F1A4D519C0A1135 /* AI/FoundationModelsAIService.swift in Sources */, + 04E8C4E18F1A4D519C0A1135 /* FoundationModelsAIService.swift in Sources */, 04E8C4E18F1A4D519C0A1104 /* AppIconView.swift in Sources */, - A0BEEF000000000000000001 /* Storefront/AppStoreWebMetadataProvider.swift in Sources */, - 04E8C4E18F1A4D519C0A1102 /* Storefront/AppIconStore.swift in Sources */, + A0BEEF000000000000000001 /* AppStoreWebMetadataProvider.swift in Sources */, + 04E8C4E18F1A4D519C0A1102 /* AppIconStore.swift in Sources */, 0A1B2C3D4E5F67890123450F /* AppFolder.swift in Sources */, 52A1A64F1D49D89D9B5DD204 /* AppPlatform.swift in Sources */, - 0B50D676CD4C516507F3F947 /* SearchRanking/AppResolver.swift in Sources */, + 0B50D676CD4C516507F3F947 /* AppResolver.swift in Sources */, 8BFC241AE4FEFAF749D3BE5A /* AppServices.swift in Sources */, - 0A5C00000000000000000001 /* AppStoreConnect/AppStoreConnectCredentials.swift in Sources */, - 0A5C00000000000000000003 /* AppStoreConnect/AppStoreConnectReviewService.swift in Sources */, - 0A1B2C3D4E5F678901234509 /* RatingsReviews/AppStorefrontRatingService.swift in Sources */, + 0A5C00000000000000000001 /* AppStoreConnectCredentials.swift in Sources */, + 0A5C00000000000000000003 /* AppStoreConnectReviewService.swift in Sources */, + 0A1B2C3D4E5F678901234509 /* AppStorefrontRatingService.swift in Sources */, 0A1B2C3D4E5F678901234511 /* AppStorefrontReview.swift in Sources */, - 0A1B2C3D4E5F678901234513 /* RatingsReviews/AppStorefrontReviewService.swift in Sources */, - A30000000000000000000001 /* Persistence/BackgroundModelStore.swift in Sources */, + 0A1B2C3D4E5F678901234513 /* AppStorefrontReviewService.swift in Sources */, + A30000000000000000000001 /* BackgroundModelStore.swift in Sources */, 04E8C4E18F1A4D519C0A1136 /* CodableAppStorage.swift in Sources */, C5A400000000000000000001 /* CSVFormats.swift in Sources */, - A0D71B4C0000000000000013 /* Persistence/DailyRefreshScheduler.swift in Sources */, - D6807507E1AF0AB52CB2C7E2 /* SearchRanking/DefaultAppResolver.swift in Sources */, - 16AD27C075641058791E8F50 /* Networking/HTTPClient.swift in Sources */, - AB171C45C65C542EFCD3CEBD /* SearchRanking/ITunesSearchFallbackProvider.swift in Sources */, - 6C612150F69149F0A3F988EE /* Credentials/KeychainService.swift in Sources */, - A0D71B4C0000000000000011 /* SearchRanking/KeywordInsightsService.swift in Sources */, + A0D71B4C0000000000000013 /* DailyRefreshScheduler.swift in Sources */, + D6807507E1AF0AB52CB2C7E2 /* DefaultAppResolver.swift in Sources */, + 16AD27C075641058791E8F50 /* HTTPClient.swift in Sources */, + AB171C45C65C542EFCD3CEBD /* ITunesSearchFallbackProvider.swift in Sources */, + 6C612150F69149F0A3F988EE /* KeychainService.swift in Sources */, + A0D71B4C0000000000000011 /* KeywordInsightsService.swift in Sources */, A0D71B4C000000000000000F /* KeywordInsightsTypes.swift in Sources */, BEEF0000000000000000000C /* KeywordAppRanking.swift in Sources */, BEEF00000000000000000008 /* KeywordDailyMetric.swift in Sources */, @@ -893,8 +900,8 @@ BEEF0000000000000000000A /* KeywordRankingCrawl.swift in Sources */, BEEF00000000000000000010 /* LatestAppRating.swift in Sources */, A10000000000000000000002 /* OpenASOSchemaV1.swift in Sources */, - 04E8C4E18F1A4D519C0A1111 /* AppleAds/KeywordMetricsService.swift in Sources */, - 04E8C4E18F1A4D519C0A1112 /* SearchRanking/KeywordSuggestionService.swift in Sources */, + 04E8C4E18F1A4D519C0A1111 /* KeywordMetricsService.swift in Sources */, + 04E8C4E18F1A4D519C0A1112 /* KeywordSuggestionService.swift in Sources */, 9F18F6F8A4D64BE38FBED4A8 /* KeywordTableView.swift in Sources */, A20000000000000000000001 /* KeywordTableCells.swift in Sources */, A20000000000000000000002 /* KeywordNotesSheet.swift in Sources */, @@ -912,20 +919,20 @@ C0DEC0DE000000000000000A /* TertiaryActionButton.swift in Sources */, A50000000000000000000001 /* PaginatedList.swift in Sources */, FEAE526BDAAC6C7B67A731ED /* OpenASOApp.swift in Sources */, - A30000000000000000000003 /* Persistence/ModelContainerFactory.swift in Sources */, - C0DEFACE0000000000000102 /* MCP/OpenASOMCPDTOs.swift in Sources */, + A30000000000000000000003 /* ModelContainerFactory.swift in Sources */, + C0DEFACE0000000000000102 /* OpenASOMCPDTOs.swift in Sources */, C0DEC0DE0000000000000012 /* MCPServerSheet.swift in Sources */, - C0DEFACE0000000000000104 /* MCP/OpenASOMCPService.swift in Sources */, - C0DEFACE0000000000000106 /* MCP/OpenASOMCPValidation.swift in Sources */, - C0DEFACE0000000000000108 /* MCP/OpenASOMCPServer.swift in Sources */, - C0DEFACE000000000000010E /* MCP/OpenASOMCPRuntime.swift in Sources */, - C0DEFACE0000000000000110 /* MCP/OpenASOMCPServerController.swift in Sources */, + C0DEFACE0000000000000104 /* OpenASOMCPService.swift in Sources */, + C0DEFACE0000000000000106 /* OpenASOMCPValidation.swift in Sources */, + C0DEFACE0000000000000108 /* OpenASOMCPServer.swift in Sources */, + C0DEFACE000000000000010E /* OpenASOMCPRuntime.swift in Sources */, + C0DEFACE0000000000000110 /* OpenASOMCPServerController.swift in Sources */, 0EBC5115A0746B143811F971 /* OpenASOError.swift in Sources */, 766FE01C27334F5387A7E797 /* PreviewAppDependencies.swift in Sources */, C0DEC0DE0000000000000006 /* IconButton.swift in Sources */, C0DEC0DE0000000000000004 /* PinIconButton.swift in Sources */, - 5FAADABB0DA1EEDB3699E66C /* SearchRanking/RankingMatcher.swift in Sources */, - 33B2BE83B4CFC8970D7A3482 /* SearchRanking/RankingRefreshCoordinator.swift in Sources */, + 5FAADABB0DA1EEDB3699E66C /* RankingMatcher.swift in Sources */, + 33B2BE83B4CFC8970D7A3482 /* RankingRefreshCoordinator.swift in Sources */, 9A6937276AF0E3CAEDA90C36 /* RankingSource.swift in Sources */, A40000000000000000000003 /* RatingsDetail.swift in Sources */, A40000000000000000000001 /* RatingsDashboardSupport.swift in Sources */, @@ -940,23 +947,25 @@ A4000000000000000000000A /* AppPricingView.swift in Sources */, A4000000000000000000000B /* AppPricingSnapshot.swift in Sources */, A4000000000000000000000C /* AppPricingPersistence.swift in Sources */, - A70000000000000000000001 /* RatingsReviews/ReviewLanguageDetectionService.swift in Sources */, - A70100000000000000000003 /* RatingsReviews/AppStorefrontRatingParser.swift in Sources */, - A70100000000000000000001 /* RatingsReviews/AppStorefrontRatingTypes.swift in Sources */, + A70000000000000000000001 /* ReviewLanguageDetectionService.swift in Sources */, + A70100000000000000000003 /* AppStorefrontRatingParser.swift in Sources */, + A70100000000000000000001 /* AppStorefrontRatingTypes.swift in Sources */, 1E6D6CC10D4806BE5ECFC851 /* RootView.swift in Sources */, 1E6D6CC10D4806BE5ECFC852 /* RootSidebarView.swift in Sources */, - C70260E8797CC353D9AD1B3A /* SearchRanking/SearchModels.swift in Sources */, - 5B3ACA5345C1963D7B861055 /* SearchRanking/SearchRankingProvider.swift in Sources */, + C70260E8797CC353D9AD1B3A /* SearchModels.swift in Sources */, + 5B3ACA5345C1963D7B861055 /* SearchRankingProvider.swift in Sources */, BEEF0000000000000000001A /* AppStoreScreenshot.swift in Sources */, BEEF00000000000000000018 /* AppStorefrontMetadata.swift in Sources */, BEEF00000000000000000006 /* StoreApp.swift in Sources */, - A90000000000000000000001 /* Updates/SparkleUpdaterController.swift in Sources */, + A90000000000000000000001 /* SparkleUpdaterController.swift in Sources */, FC2B65186E0CA31841938E40 /* Storefront.swift in Sources */, - 453E1E4BE015A629E5B3F7B5 /* Storefront/StorefrontCatalog.swift in Sources */, + 453E1E4BE015A629E5B3F7B5 /* StorefrontCatalog.swift in Sources */, 99A4FE5166338C3329CF94EE /* TrackedApp.swift in Sources */, BEEF00000000000000000012 /* TrackedAppKeyword.swift in Sources */, BEEF00000000000000000014 /* TrackedKeywordDailyRanking.swift in Sources */, BEEF00000000000000000016 /* TrackedKeywordRankedResult.swift in Sources */, + 866130558E1E4EDCF752F629 /* GlobalKeywordSync.swift in Sources */, + C41E10A81C85C97B31AC30BC /* KeywordMarketInsightsSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -990,16 +999,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C15A0000000000000000000A /* MCP/OpenASOMCPMain.swift in Sources */, - C15A00000000000000000101 /* MCP/OpenASOMCPDTOs.swift in Sources */, - C15A00000000000000000102 /* MCP/OpenASOMCPService.swift in Sources */, - C15A00000000000000000103 /* MCP/OpenASOMCPValidation.swift in Sources */, - C15A00000000000000000104 /* MCP/OpenASOMCPServer.swift in Sources */, - C15A00000000000000000105 /* MCP/OpenASOMCPRuntime.swift in Sources */, + C15A0000000000000000000A /* OpenASOMCPMain.swift in Sources */, + C15A00000000000000000101 /* OpenASOMCPDTOs.swift in Sources */, + C15A00000000000000000102 /* OpenASOMCPService.swift in Sources */, + C15A00000000000000000103 /* OpenASOMCPValidation.swift in Sources */, + C15A00000000000000000104 /* OpenASOMCPServer.swift in Sources */, + C15A00000000000000000105 /* OpenASOMCPRuntime.swift in Sources */, C15A00000000000000000106 /* AppNamespace.swift in Sources */, C15A00000000000000000107 /* OpenASOSchemaV1.swift in Sources */, - C15A00000000000000000108 /* Persistence/BackgroundModelStore.swift in Sources */, - C15A00000000000000000109 /* Persistence/ModelContainerFactory.swift in Sources */, + C15A00000000000000000108 /* BackgroundModelStore.swift in Sources */, + C15A00000000000000000109 /* ModelContainerFactory.swift in Sources */, C15A0000000000000000010A /* OpenASOError.swift in Sources */, C15A0000000000000000010B /* AppPlatform.swift in Sources */, C15A0000000000000000010C /* RankingSource.swift in Sources */, @@ -1021,17 +1030,17 @@ C15A0000000000000000011B /* TrackedKeywordDailyRanking.swift in Sources */, C15A0000000000000000011C /* TrackedKeywordRankedResult.swift in Sources */, C15A0000000000000000011D /* TrackedApp.swift in Sources */, - C15A0000000000000000011E /* Networking/HTTPClient.swift in Sources */, - C15A0000000000000000011F /* SearchRanking/AppResolver.swift in Sources */, - C15A00000000000000000120 /* SearchRanking/SearchModels.swift in Sources */, - C15A00000000000000000121 /* SearchRanking/DefaultAppResolver.swift in Sources */, - C15A00000000000000000122 /* Storefront/AppCatalogService.swift in Sources */, - C15A00000000000000000123 /* Storefront/AppStoreWebMetadataProvider.swift in Sources */, - C15A00000000000000000124 /* Storefront/ScreenshotDownloadService.swift in Sources */, + C15A0000000000000000011E /* HTTPClient.swift in Sources */, + C15A0000000000000000011F /* AppResolver.swift in Sources */, + C15A00000000000000000120 /* SearchModels.swift in Sources */, + C15A00000000000000000121 /* DefaultAppResolver.swift in Sources */, + C15A00000000000000000122 /* AppCatalogService.swift in Sources */, + C15A00000000000000000123 /* AppStoreWebMetadataProvider.swift in Sources */, + C15A00000000000000000124 /* ScreenshotDownloadService.swift in Sources */, C15A00000000000000000127 /* KeywordMetricsSource.swift in Sources */, - C15A00000000000000000125 /* RatingsReviews/AppStorefrontRatingTypes.swift in Sources */, - C15A00000000000000000126 /* RatingsReviews/AppStorefrontRatingParser.swift in Sources */, - C15A00000000000000000128 /* Storefront/StorefrontCatalog.swift in Sources */, + C15A00000000000000000125 /* AppStorefrontRatingTypes.swift in Sources */, + C15A00000000000000000126 /* AppStorefrontRatingParser.swift in Sources */, + C15A00000000000000000128 /* StorefrontCatalog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenASO/App/RootSidebarView.swift b/OpenASO/App/RootSidebarView.swift index 4177253..6e69ee9 100644 --- a/OpenASO/App/RootSidebarView.swift +++ b/OpenASO/App/RootSidebarView.swift @@ -857,6 +857,8 @@ private struct SidebarRefreshProgressView: View { return refresh.phase == .completed ? "Daily refresh complete" : refresh.phase == .failed ? "Daily refresh failed" : "Running daily refresh" case "apple_ads_connection": return refresh.phase == .completed ? "Popularity refreshed" : refresh.phase == .failed ? "Popularity refresh failed" : "Refreshing keyword popularity" + case "global_keyword_sync": + return refresh.phase == .completed ? "Global keywords synced" : refresh.phase == .failed ? "Global keyword sync failed" : "Fetching global keyword rankings" default: return refresh.phase.title } diff --git a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift index 6378868..156281c 100644 --- a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift +++ b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift @@ -340,6 +340,18 @@ struct AppDetailAddKeywordsToolbarButton: View { } } +struct AppDetailMarketInsightsToolbarButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Market Insights", systemImage: "globe") + .labelStyle(.titleAndIcon) + } + .help("Best and worst ranking markets per keyword") + } +} + struct AppDetailKeywordListsToolbarButton: View { let action: () -> Void diff --git a/OpenASO/Features/AppDetail/AppDetailView.swift b/OpenASO/Features/AppDetail/AppDetailView.swift index 752a31c..e88674f 100644 --- a/OpenASO/Features/AppDetail/AppDetailView.swift +++ b/OpenASO/Features/AppDetail/AppDetailView.swift @@ -21,6 +21,7 @@ struct AppDetailView: View { @State private var isPresentingAddKeywords = false @State private var isPresentingKeywordLists = false + @State private var isPresentingMarketInsights = false @State private var isRefreshingApp = false @State private var errorMessage: String? @State private var searchText = "" @@ -131,6 +132,9 @@ struct AppDetailView: View { if selectedWorkspaceView == .keywords { ToolbarItem(placement: .principal) { HStack(spacing: 10) { + AppDetailMarketInsightsToolbarButton { + isPresentingMarketInsights = true + } AppDetailKeywordListsToolbarButton { isPresentingKeywordLists = true } @@ -184,6 +188,9 @@ struct AppDetailView: View { ) { ManageKeywordListsSheet(trackedApp: trackedApp) } + .sheet(isPresented: $isPresentingMarketInsights) { + KeywordMarketInsightsSheet(trackedApp: trackedApp) + } .onAppear { services.analyticsService.capture(.workspaceViewed(selectedWorkspaceView)) flushQueuedKeywordAdds() diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index fdf373c..b459f19 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -425,6 +425,18 @@ struct AddKeywordsSheet: View { if savesInputToGlobalKeywords, !typedKeywords.isEmpty { persistGlobalKeywords(keywords: typedKeywords) + // Sync the updated global list to every app across all markets; + // rankings for new tracks are fetched in the background. + let templates = GlobalTrackedKeywordTemplate.decodeList(from: globalKeywordTemplatesJSON) + let syncServices = services + let syncModelContext = modelContext + Task { + _ = try? await GlobalKeywordSync.syncAndRefresh( + templates: templates, + services: syncServices, + modelContext: syncModelContext + ) + } } do { @@ -920,7 +932,7 @@ struct GlobalKeywordsSheet: View { } Text( - "One shared keyword list for all apps. Keywords here are not tracked anywhere until you apply them to apps for the countries and device you choose." + "One shared keyword list for all apps. Adding or removing keywords here syncs every tracked app automatically across all markets." ) .foregroundStyle(.secondary) @@ -934,44 +946,55 @@ struct GlobalKeywordsSheet: View { } private struct GlobalKeywordListEditorView: View { + @Environment(\.modelContext) private var modelContext + @Environment(AppServices.self) private var services @AppStorage("globalTrackedKeywordTemplatesJSON") private var globalKeywordTemplatesJSON = "[]" let defaultPlatform: AppPlatform @State private var editingEntry: KeywordListEditingEntry? @State private var isShowingResetConfirmation = false - @State private var isShowingApplySheet = false @State private var errorMessage: String? + @State private var isSyncing = false + @State private var syncStatusMessage: String? var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("\(globalKeywordTemplates.count.formatted()) reusable global keywords") .foregroundStyle(.secondary) + if isSyncing { + ProgressView() + .controlSize(.small) + Text("Syncing to apps…") + .font(.callout) + .foregroundStyle(.secondary) + } Spacer() Button { editingEntry = .newGlobal(defaultPlatform: defaultPlatform) } label: { Label("Add Global Keywords", systemImage: "plus") } - Button { - isShowingApplySheet = true - } label: { - Label("Apply to Apps", systemImage: "square.and.arrow.down.on.square") - } - .disabled(globalKeywordTemplates.isEmpty) - .help("Add the global keywords to every tracked app for chosen countries") + .disabled(isSyncing) Button(role: .destructive) { isShowingResetConfirmation = true } label: { Label("Reset Global Keywords", systemImage: "trash") } - .disabled(globalKeywordTemplates.isEmpty) + .disabled(globalKeywordTemplates.isEmpty || isSyncing) } + Text("Changes here sync to every tracked app automatically, across all markets. Rankings for new keywords are fetched in the background.") + .font(.callout) + .foregroundStyle(.secondary) + if let errorMessage { Text(errorMessage) .foregroundStyle(.red) + } else if let syncStatusMessage { + Text(syncStatusMessage) + .foregroundStyle(.secondary) } if globalKeywordTemplates.isEmpty { @@ -1013,17 +1036,12 @@ private struct GlobalKeywordListEditorView: View { .alert("Reset Global Keywords?", isPresented: $isShowingResetConfirmation) { Button("Reset Global Keywords", role: .destructive) { globalKeywordTemplatesJSON = "[]" + syncToApps() } Button("Cancel", role: .cancel) {} } message: { Text( - "Delete all \(globalKeywordTemplates.count.formatted()) reusable global keywords. App-specific tracked keywords will not be deleted." - ) - } - .sheet(isPresented: $isShowingApplySheet) { - GlobalKeywordApplySheet( - templates: globalKeywordTemplates, - defaultPlatform: defaultPlatform + "Delete all \(globalKeywordTemplates.count.formatted()) reusable global keywords. Their synced keyword tracks are removed from every app; manually added keywords are kept." ) } } @@ -1042,11 +1060,35 @@ private struct GlobalKeywordListEditorView: View { do { try saveGlobalKeywords(existingID: existingID, term: draft.normalized.term) editingEntry = nil + syncToApps() } catch { errorMessage = OpenASOError.map(error).localizedDescription } } + private func syncToApps() { + guard !isSyncing else { return } + isSyncing = true + syncStatusMessage = nil + + let templates = globalKeywordTemplates + Task { + do { + let outcome = try await GlobalKeywordSync.syncAndRefresh( + templates: templates, + services: services, + modelContext: modelContext + ) + syncStatusMessage = outcome.insertedTrackCount > 0 + ? outcome.summaryText + " Fetching rankings across all markets…" + : outcome.summaryText + } catch { + errorMessage = OpenASOError.map(error).localizedDescription + } + isSyncing = false + } + } + private func saveGlobalKeyword(existingID: String?, term: String) throws { let replacement = GlobalTrackedKeywordTemplate(term: term) var templates = globalKeywordTemplates.filter { $0.id != existingID } @@ -1092,6 +1134,7 @@ private struct GlobalKeywordListEditorView: View { globalKeywordTemplatesJSON = GlobalTrackedKeywordTemplate.encodeList( globalKeywordTemplates.filter { $0.id != template.id } ) + syncToApps() } private func parsedKeywords(from text: String) -> [String] { @@ -1108,254 +1151,6 @@ private struct GlobalKeywordListEditorView: View { } } -private struct GlobalKeywordApplySheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @Environment(AppServices.self) private var services - - @Query(sort: [SortDescriptor(\Storefront.name, order: .forward)]) - private var storefronts: [Storefront] - - @Query(sort: [SortDescriptor(\TrackedApp.name, order: .forward)]) - private var trackedApps: [TrackedApp] - - let templates: [GlobalTrackedKeywordTemplate] - - @State private var selectedStorefrontCodes: Set = ["us"] - @State private var selectedPlatform: AppPlatform - @State private var removesKeywordsMissingFromGlobalList = false - @State private var storefrontSearchText = "" - @State private var statusMessage: String? - @State private var errorMessage: String? - @State private var isApplying = false - - init(templates: [GlobalTrackedKeywordTemplate], defaultPlatform: AppPlatform) { - self.templates = templates - _selectedPlatform = State(initialValue: defaultPlatform) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Apply Global Keywords to All Apps") - .font(.title3) - .bold() - - Text( - "Adds the \(templates.count.formatted()) global keywords to every tracked app (\(trackedApps.count.formatted()) apps) for the selected countries and device. Existing keywords are kept unless you enable replace." - ) - .foregroundStyle(.secondary) - - Picker("Device", selection: $selectedPlatform) { - ForEach(AppPlatform.allCases) { platform in - Label(platform.displayName, systemImage: platform.keywordSheetSystemImage) - .tag(platform) - } - } - .pickerStyle(.segmented) - .disabled(isApplying) - - Text("Countries") - .font(.headline) - - AddKeywordsStorefrontSearchField(storefrontSearchText: $storefrontSearchText) - .disabled(isApplying) - - List(filteredStorefronts, id: \.code) { storefront in - Toggle(isOn: storefrontBinding(for: storefront.code)) { - Text(storefront.title) - } - } - .frame(minHeight: 200) - .disabled(isApplying) - - Toggle( - "Replace: remove keywords in the selected countries and device that are not in the global list", - isOn: $removesKeywordsMissingFromGlobalList - ) - .disabled(isApplying) - - if let errorMessage { - Text(errorMessage) - .foregroundStyle(.red) - } else if let statusMessage { - Text(statusMessage) - .foregroundStyle(.secondary) - } else if removesKeywordsMissingFromGlobalList { - Text("Ranking history for removed keywords is deleted.") - .foregroundStyle(.orange) - } - - HStack { - Spacer() - Button(statusMessage == nil ? "Cancel" : "Done") { - dismiss() - } - Button(removesKeywordsMissingFromGlobalList ? "Replace Keywords" : "Add Keywords") { - apply() - } - .keyboardShortcut(.defaultAction) - .disabled( - isApplying || templates.isEmpty || selectedStorefrontCodes.isEmpty - || trackedApps.isEmpty - ) - } - } - .padding(24) - .frame(minWidth: 560, minHeight: 640) - } - - private var filteredStorefronts: [Storefront] { - let normalizedSearch = storefrontSearchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedSearch.isEmpty else { return storefronts } - return storefronts.filter { storefront in - storefront.title.localizedCaseInsensitiveContains(normalizedSearch) - || storefront.code.localizedCaseInsensitiveContains(normalizedSearch) - } - } - - private func storefrontBinding(for code: String) -> Binding { - Binding( - get: { selectedStorefrontCodes.contains(code) }, - set: { isSelected in - if isSelected { - selectedStorefrontCodes.insert(code) - } else { - selectedStorefrontCodes.remove(code) - } - } - ) - } - - private func apply() { - isApplying = true - errorMessage = nil - statusMessage = nil - - let storefrontCodes = selectedStorefrontCodes - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - .sorted() - let storefrontCodeSet = Set(storefrontCodes) - let terms = templates.map(\.term) - let globalTermKeys = Set(terms.map(\.normalizedKeywordKey)) - let platform = selectedPlatform - - do { - // Resolve every needed keyword query with one batch fetch; the - // same queries are shared by all apps. - let neededQueryKeys = Array(Set(storefrontCodes.flatMap { storefront in - terms.map { - KeywordQuery.makeQueryKey(term: $0, storefront: storefront, platform: platform) - } - })) - var queriesByKey: [String: KeywordQuery] = [:] - let descriptor = FetchDescriptor( - predicate: #Predicate { query in - neededQueryKeys.contains(query.queryKey) - } - ) - for query in try modelContext.fetch(descriptor) { - queriesByKey[query.queryKey] = query - } - - var insertedCount = 0 - var removedCount = 0 - - for app in trackedApps { - if removesKeywordsMissingFromGlobalList { - let tracksToRemove = app.keywordTracks.filter { track in - storefrontCodeSet.contains( - track.storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - ) - && track.platform == platform - && !globalTermKeys.contains(track.term.normalizedKeywordKey) - } - for track in tracksToRemove { - deleteSnapshots(for: track) - modelContext.delete(track) - removedCount += 1 - } - } - - var existingKeys = Set() - for track in app.keywordTracks where !track.isDeleted { - existingKeys.insert( - trackKey(term: track.term, storefront: track.storefront, platform: track.platform) - ) - } - - for storefront in storefrontCodes { - for term in terms { - let key = trackKey(term: term, storefront: storefront, platform: platform) - guard existingKeys.insert(key).inserted else { continue } - - let queryKey = KeywordQuery.makeQueryKey( - term: term, storefront: storefront, platform: platform) - let query: KeywordQuery - if let existing = queriesByKey[queryKey] { - query = existing - } else { - query = KeywordQuery(term: term, storefront: storefront, platform: platform) - modelContext.insert(query) - queriesByKey[queryKey] = query - } - - let track = TrackedAppKeyword( - term: term, - storefront: storefront, - platform: platform, - trackedApp: app, - query: query - ) - track.notes = "Added from global keyword list." - app.keywordTracks.append(track) - modelContext.insert(track) - insertedCount += 1 - } - } - } - - try modelContext.save() - if insertedCount > 0 { - services.analyticsService.capture( - .keywordAdded(keywordCount: terms.count, storefrontCount: storefrontCodes.count)) - } - if removedCount > 0 { - services.analyticsService.capture(.keywordDeleted(deleteCount: removedCount)) - } - var summary = - "Added \(insertedCount.formatted()) keyword tracks across \(trackedApps.count.formatted()) apps." - if removesKeywordsMissingFromGlobalList { - summary += " Removed \(removedCount.formatted()) keyword tracks not in the global list." - } - summary += " Refresh apps to collect rankings." - statusMessage = summary - } catch { - errorMessage = OpenASOError.map(error).localizedDescription - } - isApplying = false - } - - private func trackKey(term: String, storefront: String, platform: AppPlatform) -> String { - [ - term.normalizedKeywordKey, - storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - platform.rawValue, - ].joined(separator: "::") - } - - private func deleteSnapshots(for track: TrackedAppKeyword) { - for snapshot in track.snapshots { - for result in snapshot.topResults { - modelContext.delete(result) - } - snapshot.topResults.removeAll() - modelContext.delete(snapshot) - } - track.snapshots.removeAll() - } -} - private enum KeywordListScope: CaseIterable, Identifiable { case local case global @@ -1624,7 +1419,7 @@ private enum KeywordListEditingSource { } extension String { - fileprivate var normalizedKeywordKey: String { + var normalizedKeywordKey: String { trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } } diff --git a/OpenASO/Features/AppDetail/Keywords/KeywordMarketInsightsSheet.swift b/OpenASO/Features/AppDetail/Keywords/KeywordMarketInsightsSheet.swift new file mode 100644 index 0000000..a7c649a --- /dev/null +++ b/OpenASO/Features/AppDetail/Keywords/KeywordMarketInsightsSheet.swift @@ -0,0 +1,285 @@ +import SwiftData +import SwiftUI + +// Shows, for every tracked keyword, where the app currently ranks best and +// worst across all tracked markets. +struct KeywordMarketInsightsSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppServices.self) private var services + + let trackedApp: TrackedApp + + @Query private var tracks: [TrackedAppKeyword] + + @State private var rows: [KeywordMarketInsightRow] = [] + @State private var searchText = "" + @State private var sortOrder = [ + KeyPathComparator(\KeywordMarketInsightRow.bestRankSortValue) + ] + + init(trackedApp: TrackedApp) { + self.trackedApp = trackedApp + + let appStoreID = trackedApp.appStoreID + _tracks = Query( + filter: #Predicate { track in + track.appStoreID == appStoreID + }, + sort: [ + SortDescriptor(\TrackedAppKeyword.term, order: .forward), + SortDescriptor(\TrackedAppKeyword.storefront, order: .forward), + ] + ) + } + + private var filteredRows: [KeywordMarketInsightRow] { + let normalizedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let matching = normalizedSearch.isEmpty + ? rows + : rows.filter { $0.keyword.localizedCaseInsensitiveContains(normalizedSearch) } + return matching.sorted(using: sortOrder) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Market Insights") + .font(.title2) + .bold() + Text("Where \(trackedApp.name) ranks highest and lowest per keyword across all tracked markets.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + + Divider() + + if rows.isEmpty { + ContentUnavailableView( + "No Ranked Keywords Yet", + systemImage: "globe", + description: Text("Refresh keywords to collect rankings across markets first.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Table(filteredRows, sortOrder: $sortOrder) { + TableColumn("Keyword", value: \.keywordSortValue) { row in + Text(row.keyword) + .font(.body.weight(.medium)) + } + .width(min: 160, ideal: 200) + + TableColumn("Device", value: \.platformSortValue) { row in + Text(row.platformDisplayName) + .foregroundStyle(.secondary) + } + .width(min: 60, ideal: 70, max: 90) + + TableColumn("Best Market", value: \.bestRankSortValue) { row in + KeywordMarketRankCell(market: row.bestMarket, style: .best) + } + .width(min: 170, ideal: 210) + + TableColumn("Worst Market", value: \.worstRankSortValue) { row in + KeywordMarketRankCell(market: row.worstMarket, style: .worst) + } + .width(min: 170, ideal: 210) + + TableColumn("Best–Worst Spread", value: \.spreadSortValue) { row in + Text(row.spreadText) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .width(min: 110, ideal: 130, max: 150) + + TableColumn("Avg. Rank", value: \.averageRankSortValue) { row in + Text(row.averageRankText) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .width(min: 80, ideal: 90, max: 110) + + TableColumn("Ranked Markets", value: \.rankedCountSortValue) { row in + Text("\(row.rankedMarketCount.formatted()) / \(row.trackedMarketCount.formatted())") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .width(min: 110, ideal: 120, max: 140) + } + } + + Divider() + + HStack { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter keywords", text: $searchText) + .textFieldStyle(.plain) + .frame(maxWidth: 220) + } + Spacer() + Text("\(filteredRows.count.formatted()) keywords across \(trackedMarketCount.formatted()) markets") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + } + .frame(minWidth: 1_060, idealWidth: 1_180, minHeight: 640, idealHeight: 820) + .task(id: trackReloadSignature) { + rebuildRows() + } + } + + private var trackedMarketCount: Int { + Set(tracks.map(\.storefront)).count + } + + private var trackReloadSignature: Int { + var hasher = Hasher() + hasher.combine(services.backgroundModelStoreRevision) + hasher.combine(tracks.count) + for track in tracks { + hasher.combine(track.identityKey) + hasher.combine(track.lastRefreshAt) + } + return hasher.finalize() + } + + private func rebuildRows() { + let storefrontTitles = storefrontTitleLookup + let grouped = Dictionary(grouping: tracks) { track in + [track.term.normalizedKeywordKey, track.platformRaw].joined(separator: "::") + } + + rows = grouped.values.compactMap { group -> KeywordMarketInsightRow? in + guard let sample = group.first else { return nil } + + let markets: [KeywordMarketInsightRow.Market] = group.map { track in + let latest = track.latestSnapshot + return KeywordMarketInsightRow.Market( + storefront: track.storefront, + title: storefrontTitles[track.storefront] ?? track.storefront.uppercased(), + rank: latest?.rank, + searchedAt: latest?.searchedAt ?? track.lastRefreshAt + ) + } + let ranked = markets + .filter { $0.rank != nil } + .sorted { ($0.rank ?? .max) < ($1.rank ?? .max) } + + let rankValues = ranked.compactMap(\.rank) + return KeywordMarketInsightRow( + keyword: sample.term, + platformDisplayName: sample.platform.displayName, + platformRaw: sample.platformRaw, + bestMarket: ranked.first, + worstMarket: ranked.last, + averageRank: rankValues.isEmpty + ? nil + : Double(rankValues.reduce(0, +)) / Double(rankValues.count), + rankedMarketCount: ranked.count, + trackedMarketCount: markets.count + ) + } + } + + private var storefrontTitleLookup: [String: String] { + let storefronts = (try? services.storefrontCatalog.bundledStorefronts()) ?? [] + return Dictionary(uniqueKeysWithValues: storefronts.map { + ($0.code.lowercased(), "\($0.flagEmoji) \($0.name)") + }) + } +} + +struct KeywordMarketInsightRow: Identifiable { + struct Market { + let storefront: String + let title: String + let rank: Int? + let searchedAt: Date? + } + + let keyword: String + let platformDisplayName: String + let platformRaw: String + let bestMarket: Market? + let worstMarket: Market? + let averageRank: Double? + let rankedMarketCount: Int + let trackedMarketCount: Int + + var id: String { [keyword.lowercased(), platformRaw].joined(separator: "::") } + + var keywordSortValue: String { keyword.localizedLowercase } + var platformSortValue: String { platformDisplayName } + var bestRankSortValue: Int { bestMarket?.rank ?? .max } + var worstRankSortValue: Int { worstMarket?.rank ?? .max } + var averageRankSortValue: Double { averageRank ?? .greatestFiniteMagnitude } + var rankedCountSortValue: Int { rankedMarketCount } + var spreadSortValue: Int { + guard let best = bestMarket?.rank, let worst = worstMarket?.rank else { return .max } + return worst - best + } + + var spreadText: String { + guard let best = bestMarket?.rank, let worst = worstMarket?.rank else { return "-" } + guard worst > best else { return "0" } + return (worst - best).formatted() + } + + var averageRankText: String { + guard let averageRank else { return "-" } + return averageRank.formatted(.number.precision(.fractionLength(0...1))) + } +} + +private struct KeywordMarketRankCell: View { + enum Style { + case best + case worst + } + + let market: KeywordMarketInsightRow.Market? + let style: Style + + var body: some View { + if let market, let rank = market.rank { + HStack(spacing: 6) { + Text("#\(rank)") + .font(.body.monospacedDigit().weight(.semibold)) + .foregroundStyle(rankColor(rank)) + .frame(width: 46, alignment: .trailing) + Text(market.title) + .lineLimit(1) + } + } else { + Text("Not ranked") + .foregroundStyle(.secondary) + } + } + + private func rankColor(_ rank: Int) -> Color { + switch style { + case .best: + if rank <= 10 { return .green } + if rank <= 50 { return .primary } + return .secondary + case .worst: + if rank > 100 { return .orange } + return .secondary + } + } +} diff --git a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift index 8be4578..98aabe2 100644 --- a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift +++ b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift @@ -200,6 +200,27 @@ final class AppRefreshProgressStore: Sendable { ) } + func beginGlobalKeywordSync(trackTotal: Int) { + clearTask?.cancel() + clearTask = nil + + activeRefresh = AppRefreshProgress( + id: UUID(), + appStoreID: 0, + appName: "Global keywords", + trigger: "global_keyword_sync", + startedAt: .now, + phase: .refreshingKeywords, + keywordProgress: .pending(total: trackTotal), + metricsProgress: .pending(total: trackTotal), + ratingsProgress: .pending(total: 0), + reviewsProgress: .pending(total: 0), + pricingProgress: .pending(total: 0), + completedAt: nil, + errorMessage: nil + ) + } + func beginAppleAdsPopularityRefresh(total: Int) { clearTask?.cancel() clearTask = nil diff --git a/OpenASO/Services/AppDetail/GlobalKeywordSync.swift b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift new file mode 100644 index 0000000..cdcf58e --- /dev/null +++ b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift @@ -0,0 +1,254 @@ +import Foundation +import SwiftData + +// Reconciles every tracked app's keyword tracks with the shared global +// keyword list. Global keywords are tracked across all bundled storefronts +// using each app's default platform. Tracks created by this sync carry a +// marker note so removals never touch manually added keywords. +enum GlobalKeywordSync { + static let globalTrackNote = "Added from global keyword list." + + struct Outcome: Sendable { + var appCount = 0 + var insertedTrackCount = 0 + var removedTrackCount = 0 + + var summaryText: String { + var parts: [String] = [] + if insertedTrackCount > 0 { + parts.append("added \(insertedTrackCount.formatted()) keyword tracks") + } + if removedTrackCount > 0 { + parts.append("removed \(removedTrackCount.formatted())") + } + guard !parts.isEmpty else { + return "All \(appCount.formatted()) apps already in sync." + } + return "Synced \(appCount.formatted()) apps: \(parts.joined(separator: ", "))." + } + } + + // Reconcile all apps' tracks with the global list. Storefront codes are + // normally every bundled storefront so rankings cover all markets. + static func sync( + templates: [GlobalTrackedKeywordTemplate], + storefrontCodes: [String], + in modelContext: ModelContext + ) throws -> Outcome { + let terms = templates.map(\.term).filter { !$0.isEmpty } + let termKeys = Set(terms.map(\.normalizedKeywordKey)) + let normalizedStorefronts = Array(Set( + storefrontCodes + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + )).sorted() + + var outcome = Outcome() + let apps = try modelContext.fetch(FetchDescriptor( + sortBy: [SortDescriptor(\.name, order: .forward)] + )) + outcome.appCount = apps.count + + // Remove sync-created tracks whose term left the global list. + let markerNote = globalTrackNote + let markedDescriptor = FetchDescriptor( + predicate: #Predicate { track in + track.notes == markerNote + } + ) + for track in try modelContext.fetch(markedDescriptor) + where !termKeys.contains(track.term.normalizedKeywordKey) { + deleteSnapshots(for: track, in: modelContext) + modelContext.delete(track) + outcome.removedTrackCount += 1 + } + + guard !apps.isEmpty, !terms.isEmpty, !normalizedStorefronts.isEmpty else { + if outcome.removedTrackCount > 0 { + try modelContext.save() + } + return outcome + } + + // Resolve every needed keyword query with one batch fetch; queries are + // shared across apps. + let platforms = Set(apps.map(\.defaultPlatform)) + var neededQueryKeySet: Set = [] + for platform in platforms { + for storefront in normalizedStorefronts { + for term in terms { + neededQueryKeySet.insert( + KeywordQuery.makeQueryKey(term: term, storefront: storefront, platform: platform) + ) + } + } + } + var queriesByKey: [String: KeywordQuery] = [:] + let targetQueryKeys = Array(neededQueryKeySet) + let queryDescriptor = FetchDescriptor( + predicate: #Predicate { query in + targetQueryKeys.contains(query.queryKey) + } + ) + for query in try modelContext.fetch(queryDescriptor) { + queriesByKey[query.queryKey] = query + } + + for app in apps { + let platform = app.defaultPlatform + var existingKeys = Set() + for track in app.keywordTracks where !track.isDeleted { + existingKeys.insert( + trackKey(term: track.term, storefront: track.storefront, platform: track.platform) + ) + } + + for storefront in normalizedStorefronts { + for term in terms { + let key = trackKey(term: term, storefront: storefront, platform: platform) + guard existingKeys.insert(key).inserted else { continue } + + let queryKey = KeywordQuery.makeQueryKey( + term: term, storefront: storefront, platform: platform) + let query: KeywordQuery + if let existing = queriesByKey[queryKey] { + query = existing + } else { + query = KeywordQuery(term: term, storefront: storefront, platform: platform) + modelContext.insert(query) + queriesByKey[queryKey] = query + } + + let track = TrackedAppKeyword( + term: term, + storefront: storefront, + platform: platform, + trackedApp: app, + query: query + ) + track.notes = markerNote + app.keywordTracks.append(track) + modelContext.insert(track) + outcome.insertedTrackCount += 1 + } + } + } + + try modelContext.save() + return outcome + } + + // Sync the global list to all apps and kick a background ranking and + // metrics refresh for the tracks that were just created. + @MainActor + static func syncAndRefresh( + templates: [GlobalTrackedKeywordTemplate], + services: AppServices, + modelContext: ModelContext + ) async throws -> Outcome { + let storefrontCodes = try StorefrontCatalog.bundledStorefrontCodes() + + let outcome: Outcome + if let backgroundModelStore = services.backgroundModelStore { + outcome = try await backgroundModelStore.write { backgroundContext in + try sync( + templates: templates, + storefrontCodes: storefrontCodes, + in: backgroundContext + ) + } + services.markBackgroundModelStoreChanged() + } else { + outcome = try sync( + templates: templates, + storefrontCodes: storefrontCodes, + in: modelContext + ) + } + + if outcome.insertedTrackCount > 0 { + Task { @MainActor in + await refreshUnfetchedSyncedTracks(services: services, in: modelContext) + } + } + return outcome + } + + // Fetch rankings and metrics for sync-created tracks that have never been + // refreshed. Runs one coordinator pass so identical keyword+market queries + // shared by multiple apps are fetched exactly once. + @MainActor + static func refreshUnfetchedSyncedTracks( + services: AppServices, + in modelContext: ModelContext + ) async { + let markerNote = globalTrackNote + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.notes == markerNote && track.lastRefreshAt == nil + } + ) + guard let tracks = try? modelContext.fetch(descriptor), !tracks.isEmpty else { return } + + let progressStore = services.refreshProgressStore + progressStore.beginGlobalKeywordSync(trackTotal: tracks.count) + + _ = await services.refreshCoordinator.refresh( + tracks: tracks, + in: modelContext, + analyticsTrigger: "global_keyword_sync", + progress: { completed, total, failureCount in + await progressStore.updateStep( + .keywords, + status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, + completed: completed, + total: total, + failureCount: failureCount + ) + } + ) + + var metricsErrorMessage: String? + if let backgroundModelStore = services.backgroundModelStore { + let identityKeys = tracks.map(\.identityKey) + let outcomes = try? await services.keywordMetricsService.refreshMetrics( + for: identityKeys, + popularityContextAppStoreID: services.settingsStore.popularityContextAppStoreID, + webSession: services.appleAdsWebSessionStore.session, + using: backgroundModelStore, + progress: { completed, total, failureCount in + await progressStore.updateStep( + .metrics, + status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, + completed: completed, + total: total, + failureCount: failureCount + ) + } + ) + metricsErrorMessage = outcomes?.first { $0.errorMessage != nil }?.errorMessage + } + + services.markBackgroundModelStoreChanged() + progressStore.finish(error: metricsErrorMessage.map(OpenASOError.providerUnavailable)) + } + + private static func trackKey(term: String, storefront: String, platform: AppPlatform) -> String { + [ + term.normalizedKeywordKey, + storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + platform.rawValue, + ].joined(separator: "::") + } + + private static func deleteSnapshots(for track: TrackedAppKeyword, in modelContext: ModelContext) { + for snapshot in track.snapshots { + for result in snapshot.topResults { + modelContext.delete(result) + } + snapshot.topResults.removeAll() + modelContext.delete(snapshot) + } + track.snapshots.removeAll() + } +} diff --git a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift index d105404..c5e2fb2 100644 --- a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift +++ b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift @@ -230,9 +230,44 @@ struct OpenASOMCPGlobalKeywordTemplate: Codable, Identifiable, Sendable { let createdAt: Date } +struct OpenASOMCPGlobalKeywordSyncSummary: Codable, Sendable { + let syncedAppCount: Int + let insertedTrackCount: Int + let removedTrackCount: Int + let storefrontCount: Int + let note: String +} + struct OpenASOMCPGlobalKeywordMutationResult: Codable, Sendable { let templates: [OpenASOMCPGlobalKeywordTemplate] let summary: OpenASOMCPMutationSummary + let sync: OpenASOMCPGlobalKeywordSyncSummary? +} + +struct OpenASOMCPKeywordMarketRank: Codable, Sendable { + let storefront: String + let rank: Int? + let resultCount: Int? + let searchedAt: Date? +} + +struct OpenASOMCPKeywordMarketRankings: Codable, Identifiable, Sendable { + let keyword: String + let platform: String + let marketsTracked: Int + let marketsRanked: Int + let bestMarket: OpenASOMCPKeywordMarketRank? + let worstMarket: OpenASOMCPKeywordMarketRank? + let averageRank: Double? + let markets: [OpenASOMCPKeywordMarketRank] + + var id: String { [keyword.lowercased(), platform].joined(separator: "::") } +} + +struct OpenASOMCPKeywordMarketRankingsResult: Codable, Sendable { + let appStoreID: String + let platform: String? + let items: [OpenASOMCPKeywordMarketRankings] } struct OpenASOMCPKeywordScoreResult: Codable, Sendable { diff --git a/OpenASO/Services/MCP/OpenASOMCPServer.swift b/OpenASO/Services/MCP/OpenASOMCPServer.swift index 42875a9..11f5ac2 100644 --- a/OpenASO/Services/MCP/OpenASOMCPServer.swift +++ b/OpenASO/Services/MCP/OpenASOMCPServer.swift @@ -238,7 +238,7 @@ struct OpenASOMCPServerFactory: Sendable { return try Self.toolResult(result) case "add_global_keywords": - let result = try service.addGlobalKeywords( + let result = try await service.addGlobalKeywords( keywords: try arguments.requiredStringArray("keywords") ) return try Self.toolResult(result) @@ -276,18 +276,26 @@ struct OpenASOMCPServerFactory: Sendable { return try Self.toolResult(result) case "update_global_keyword": - let result = try service.updateGlobalKeyword( + let result = try await service.updateGlobalKeyword( id: try arguments.requiredString("id"), keyword: try arguments.requiredString("keyword") ) return try Self.toolResult(result) case "delete_global_keyword": - let result = service.deleteGlobalKeyword(id: try arguments.requiredString("id")) + let result = try await service.deleteGlobalKeyword(id: try arguments.requiredString("id")) return try Self.toolResult(result) case "reset_global_keywords": - let result = service.resetGlobalKeywords() + let result = try await service.resetGlobalKeywords() + return try Self.toolResult(result) + + case "list_keyword_market_rankings": + let result = try await service.listKeywordMarketRankings( + appStoreID: try arguments.requiredInt64("appStoreID"), + platform: arguments.string("platform"), + keyword: arguments.string("keyword") + ) return try Self.toolResult(result) case "list_screenshots": @@ -543,7 +551,7 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer, "cursor": .string]) { current, _ in current } ), readOnly: true), - tool("list_global_keywords", "List the reusable global keyword terms. Global keywords are storefront-agnostic; use add_keywords to apply them to an app for specific storefronts.", schema( + tool("list_global_keywords", "List the reusable global keyword terms. Global keywords automatically sync as tracked keywords to every app across all markets.", schema( optional: [:] ), readOnly: true), tool("score_keywords", "Classify tracked keywords into defend, attack, long-tail, brand, experimental, or noisy buckets using rank, popularity, and phrase-quality heuristics.", schema( @@ -554,7 +562,7 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID", "keywords", "storefronts"], optional: ["appStoreID": .integer, "keywords": .stringArray, "storefronts": .stringArray, "platform": .string] ), readOnly: false, destructive: false, idempotent: true), - tool("add_global_keywords", "Add reusable global keyword terms. Global keywords are storefront-agnostic and are not tracked for any app until applied with add_keywords.", schema( + tool("add_global_keywords", "Add reusable global keyword terms. Automatically syncs keyword tracks to every app across all markets; call refresh_keywords per app afterwards to fetch rankings.", schema( required: ["keywords"], optional: ["keywords": .stringArray] ), readOnly: false, destructive: false, idempotent: true), @@ -577,10 +585,14 @@ private extension OpenASOMCPServerFactory { optional: ["track_identity_key": .string] ), readOnly: false, destructive: true, idempotent: false), tool("reset_keywords", "Delete all tracked keywords for one app.", appIDSchema, readOnly: false, destructive: true, idempotent: false), - tool("update_global_keyword", "Rename one reusable global keyword term by id.", schema( + tool("update_global_keyword", "Rename one reusable global keyword term by id. Synced keyword tracks across all apps and markets are updated automatically.", schema( required: ["id", "keyword"], optional: ["id": .string, "keyword": .string] ), readOnly: false, destructive: false, idempotent: true), + tool("list_keyword_market_rankings", "Per keyword, show where the app ranks best and worst across all tracked markets, with per-market ranks.", schema( + required: ["appStoreID"], + optional: ["appStoreID": .integer, "platform": .string, "keyword": .string] + ), readOnly: true), tool("delete_global_keyword", "Delete one reusable global keyword template by id.", schema( required: ["id"], optional: ["id": .string] diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 753cad8..61bd8e0 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -412,7 +412,7 @@ final class OpenASOMCPService: Sendable { Self.globalKeywordTemplates().map(Self.globalKeywordTemplate) } - func addGlobalKeywords(keywords: [String]) throws -> OpenASOMCPGlobalKeywordMutationResult { + func addGlobalKeywords(keywords: [String]) async throws -> OpenASOMCPGlobalKeywordMutationResult { let keywords = try OpenASOMCPValidation.keywords(keywords) var templates = Self.globalKeywordTemplates() @@ -431,6 +431,7 @@ final class OpenASOMCPService: Sendable { } Self.setGlobalKeywordTemplates(templates) + let sync = try await syncGlobalKeywordTracks(templates: templates) return OpenASOMCPGlobalKeywordMutationResult( templates: templates.map(Self.globalKeywordTemplate), summary: OpenASOMCPMutationSummary( @@ -439,14 +440,15 @@ final class OpenASOMCPService: Sendable { skipped: skippedCount, refreshed: 0, failed: 0 - ) + ), + sync: sync ) } func updateGlobalKeyword( id: String, keyword: String - ) throws -> OpenASOMCPGlobalKeywordMutationResult { + ) async throws -> OpenASOMCPGlobalKeywordMutationResult { let keyword = try OpenASOMCPValidation.keyword(keyword) var templates = Self.globalKeywordTemplates() guard let index = templates.firstIndex(where: { $0.id == id }) else { @@ -463,6 +465,7 @@ final class OpenASOMCPService: Sendable { } templates[index] = replacement Self.setGlobalKeywordTemplates(templates) + let sync = try await syncGlobalKeywordTracks(templates: templates) return OpenASOMCPGlobalKeywordMutationResult( templates: templates.map(Self.globalKeywordTemplate), summary: OpenASOMCPMutationSummary( @@ -471,14 +474,16 @@ final class OpenASOMCPService: Sendable { skipped: 0, refreshed: 0, failed: 0 - ) + ), + sync: sync ) } - func deleteGlobalKeyword(id: String) -> OpenASOMCPGlobalKeywordMutationResult { + func deleteGlobalKeyword(id: String) async throws -> OpenASOMCPGlobalKeywordMutationResult { let templates = Self.globalKeywordTemplates() let retained = templates.filter { $0.id != id } Self.setGlobalKeywordTemplates(retained) + let sync = try await syncGlobalKeywordTracks(templates: retained) return OpenASOMCPGlobalKeywordMutationResult( templates: retained.map(Self.globalKeywordTemplate), summary: OpenASOMCPMutationSummary( @@ -487,13 +492,15 @@ final class OpenASOMCPService: Sendable { skipped: 0, refreshed: 0, failed: 0 - ) + ), + sync: sync ) } - func resetGlobalKeywords() -> OpenASOMCPGlobalKeywordMutationResult { + func resetGlobalKeywords() async throws -> OpenASOMCPGlobalKeywordMutationResult { let templates = Self.globalKeywordTemplates() Self.setGlobalKeywordTemplates([]) + let sync = try await syncGlobalKeywordTracks(templates: []) return OpenASOMCPGlobalKeywordMutationResult( templates: [], summary: OpenASOMCPMutationSummary( @@ -502,10 +509,113 @@ final class OpenASOMCPService: Sendable { skipped: 0, refreshed: 0, failed: 0 + ), + sync: sync + ) + } + + // Reconciles every app's keyword tracks with the global list across all + // bundled storefronts. Rankings are not fetched here; call refresh_keywords + // per app afterwards. + private func syncGlobalKeywordTracks( + templates: [GlobalTrackedKeywordTemplate] + ) async throws -> OpenASOMCPGlobalKeywordSyncSummary { + let storefrontCodes = try StorefrontCatalog.bundledStorefrontCodes() + let outcome = try await backgroundModelStore.write { modelContext in + try GlobalKeywordSync.sync( + templates: templates, + storefrontCodes: storefrontCodes, + in: modelContext ) + } + return OpenASOMCPGlobalKeywordSyncSummary( + syncedAppCount: outcome.appCount, + insertedTrackCount: outcome.insertedTrackCount, + removedTrackCount: outcome.removedTrackCount, + storefrontCount: storefrontCodes.count, + note: outcome.insertedTrackCount > 0 + ? "New keyword tracks have no rankings yet. Call refresh_keywords for each app to fetch rankings across all markets." + : "All apps already in sync." ) } + func listKeywordMarketRankings( + appStoreID: Int64, + platform: String? = nil, + keyword: String? = nil + ) async throws -> OpenASOMCPKeywordMarketRankingsResult { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let platform = try platform.map(OpenASOMCPValidation.platform) + let keywordFilter = keyword? + .trimmingCharacters(in: .whitespacesAndNewlines) + .normalizedKeywordKey + + return try await backgroundModelStore.read { modelContext in + let descriptor = FetchDescriptor( + predicate: #Predicate { track in + track.appStoreID == appStoreID + } + ) + let tracks = try modelContext.fetch(descriptor).filter { track in + if let platform, track.platform != platform { return false } + if let keywordFilter, !keywordFilter.isEmpty, + track.term.normalizedKeywordKey != keywordFilter + { + return false + } + return true + } + + let grouped = Dictionary(grouping: tracks) { track in + [track.term.normalizedKeywordKey, track.platformRaw].joined(separator: "::") + } + let items = grouped.values.compactMap { group -> OpenASOMCPKeywordMarketRankings? in + guard let sample = group.first else { return nil } + + let markets = group.map { track -> OpenASOMCPKeywordMarketRank in + let latest = track.latestSnapshot + return OpenASOMCPKeywordMarketRank( + storefront: track.storefront, + rank: latest?.rank, + resultCount: latest?.resultCount ?? track.rankingAppCount, + searchedAt: latest?.searchedAt ?? track.lastRefreshAt + ) + } + let rankedMarkets = markets + .filter { $0.rank != nil } + .sorted { ($0.rank ?? .max) < ($1.rank ?? .max) } + let rankValues = rankedMarkets.compactMap(\.rank) + + return OpenASOMCPKeywordMarketRankings( + keyword: sample.term, + platform: sample.platformRaw, + marketsTracked: markets.count, + marketsRanked: rankedMarkets.count, + bestMarket: rankedMarkets.first, + worstMarket: rankedMarkets.last, + averageRank: rankValues.isEmpty + ? nil + : Double(rankValues.reduce(0, +)) / Double(rankValues.count), + markets: markets.sorted { ($0.rank ?? .max, $0.storefront) < ($1.rank ?? .max, $1.storefront) } + ) + } + .sorted { lhs, rhs in + let lhsBest = lhs.bestMarket?.rank ?? .max + let rhsBest = rhs.bestMarket?.rank ?? .max + if lhsBest == rhsBest { + return lhs.keyword.localizedStandardCompare(rhs.keyword) == .orderedAscending + } + return lhsBest < rhsBest + } + + return OpenASOMCPKeywordMarketRankingsResult( + appStoreID: String(appStoreID), + platform: platform?.rawValue, + items: items + ) + } + } + func scoreKeywords( appStoreID: Int64, storefronts: [String]? = nil, From c13ea72672aa14480e2f0045045a7ab125c6a7a5 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 01:53:42 +0530 Subject: [PATCH 7/8] fix: Crash sorting TrackedApp fetches by computed name property SwiftData traps with "Couldn't find \TrackedApp.name" when a FetchDescriptor or @Query sorts on TrackedApp.name, which is computed rather than a schema field. Sort GlobalKeywordSync's app fetch and GlobalKeywordsSheet's query by appStoreID instead, and add a GlobalKeywordSync regression test covering cross-storefront insert and removal through the background store. Co-Authored-By: Claude Fable 5 --- .../AppDetail/Keywords/AddKeywordsSheet.swift | 3 +- .../AppDetail/GlobalKeywordSync.swift | 3 +- .../RankingRefreshCoordinatorTests.swift | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift index b459f19..ed65167 100644 --- a/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift +++ b/OpenASO/Features/AppDetail/Keywords/AddKeywordsSheet.swift @@ -915,7 +915,8 @@ struct ManageKeywordListsSheet: View { struct GlobalKeywordsSheet: View { @Environment(\.dismiss) private var dismiss - @Query(sort: [SortDescriptor(\TrackedApp.name, order: .forward)]) + // TrackedApp.name is computed, not a schema field — do not sort on it. + @Query(sort: [SortDescriptor(\TrackedApp.appStoreID, order: .forward)]) private var trackedApps: [TrackedApp] var body: some View { diff --git a/OpenASO/Services/AppDetail/GlobalKeywordSync.swift b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift index cdcf58e..5626615 100644 --- a/OpenASO/Services/AppDetail/GlobalKeywordSync.swift +++ b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift @@ -44,8 +44,9 @@ enum GlobalKeywordSync { )).sorted() var outcome = Outcome() + // TrackedApp.name is computed, not a schema field — sort in memory. let apps = try modelContext.fetch(FetchDescriptor( - sortBy: [SortDescriptor(\.name, order: .forward)] + sortBy: [SortDescriptor(\.appStoreID, order: .forward)] )) outcome.appCount = apps.count diff --git a/OpenASOTests/RankingRefreshCoordinatorTests.swift b/OpenASOTests/RankingRefreshCoordinatorTests.swift index da4b6b8..718c784 100644 --- a/OpenASOTests/RankingRefreshCoordinatorTests.swift +++ b/OpenASOTests/RankingRefreshCoordinatorTests.swift @@ -868,6 +868,63 @@ private actor ControlledRatingsHTTPClient: HTTPClient { } } +@MainActor +struct GlobalKeywordSyncTests { + @Test + func syncCreatesAndRemovesTracksAcrossStorefronts() async throws { + let container = try makeInMemoryContainer() + let modelContext = ModelContext(container) + modelContext.insert(TrackedApp( + appStoreID: 842842640, + bundleID: "com.google.Docs", + name: "Google Docs", + sellerName: "Google", + defaultPlatform: .iphone + )) + modelContext.insert(TrackedApp( + appStoreID: 361309726, + bundleID: "com.google.Sheets", + name: "Google Sheets", + sellerName: "Google", + defaultPlatform: .iphone + )) + try modelContext.save() + + let store = BackgroundModelStore(modelContainer: container) + let templates = [ + GlobalTrackedKeywordTemplate(term: "notes"), + GlobalTrackedKeywordTemplate(term: "docs editor"), + ] + + let outcome = try await store.write { context in + try GlobalKeywordSync.sync( + templates: templates, + storefrontCodes: ["us", "gb", "de"], + in: context + ) + } + + #expect(outcome.appCount == 2) + #expect(outcome.insertedTrackCount == 12) + #expect(outcome.removedTrackCount == 0) + + // Removing a keyword cleans its synced tracks everywhere. + let secondOutcome = try await store.write { context in + try GlobalKeywordSync.sync( + templates: [GlobalTrackedKeywordTemplate(term: "notes")], + storefrontCodes: ["us", "gb", "de"], + in: context + ) + } + #expect(secondOutcome.insertedTrackCount == 0) + #expect(secondOutcome.removedTrackCount == 6) + + let remaining = try modelContext.fetch(FetchDescriptor()) + #expect(remaining.count == 6) + #expect(remaining.allSatisfy { $0.term == "notes" }) + } +} + private func makeInMemoryContainer() throws -> ModelContainer { let schema = Schema([ AppFolder.self, From fe378e9ba02c55985bc71f188a740f51cf830bb1 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Sat, 11 Jul 2026 02:02:36 +0530 Subject: [PATCH 8/8] fix: Crash from concurrent ranking saves during global keyword sync fetch The sync's ranking fetch ran on the main-context coordinator while user-triggered app refreshes persisted the same unique-keyed crawls on the background context; CoreData's merge policy throws on the resulting to-many union merge (NSMergePolicy _mergeToManyUnionRelationships) and aborts. Route the post-sync fetch through the AppDetailRefreshService queue as one combined request instead: all persistence stays serialized on the background store, shared keyword+market queries across apps are fetched once, and the sidebar shows a single "Global keywords" progress card. Co-Authored-By: Claude Fable 5 --- .../AppDetail/AppRefreshProgressStore.swift | 21 ------ .../AppDetail/GlobalKeywordSync.swift | 68 ++++++++----------- 2 files changed, 28 insertions(+), 61 deletions(-) diff --git a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift index 98aabe2..8be4578 100644 --- a/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift +++ b/OpenASO/Services/AppDetail/AppRefreshProgressStore.swift @@ -200,27 +200,6 @@ final class AppRefreshProgressStore: Sendable { ) } - func beginGlobalKeywordSync(trackTotal: Int) { - clearTask?.cancel() - clearTask = nil - - activeRefresh = AppRefreshProgress( - id: UUID(), - appStoreID: 0, - appName: "Global keywords", - trigger: "global_keyword_sync", - startedAt: .now, - phase: .refreshingKeywords, - keywordProgress: .pending(total: trackTotal), - metricsProgress: .pending(total: trackTotal), - ratingsProgress: .pending(total: 0), - reviewsProgress: .pending(total: 0), - pricingProgress: .pending(total: 0), - completedAt: nil, - errorMessage: nil - ) - } - func beginAppleAdsPopularityRefresh(total: Int) { clearTask?.cancel() clearTask = nil diff --git a/OpenASO/Services/AppDetail/GlobalKeywordSync.swift b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift index 5626615..94f9098 100644 --- a/OpenASO/Services/AppDetail/GlobalKeywordSync.swift +++ b/OpenASO/Services/AppDetail/GlobalKeywordSync.swift @@ -176,13 +176,18 @@ enum GlobalKeywordSync { } // Fetch rankings and metrics for sync-created tracks that have never been - // refreshed. Runs one coordinator pass so identical keyword+market queries - // shared by multiple apps are fetched exactly once. + // refreshed. Runs as ONE request through the AppDetailRefreshService queue + // so all persistence stays serialized on the background store (concurrent + // saves of the same unique-keyed crawls crash CoreData's merge policy), + // and identical keyword+market queries shared by multiple apps are fetched + // exactly once. @MainActor static func refreshUnfetchedSyncedTracks( services: AppServices, in modelContext: ModelContext ) async { + guard let refreshService = services.appDetailRefreshService else { return } + let markerNote = globalTrackNote let descriptor = FetchDescriptor( predicate: #Predicate { track in @@ -191,47 +196,30 @@ enum GlobalKeywordSync { ) guard let tracks = try? modelContext.fetch(descriptor), !tracks.isEmpty else { return } - let progressStore = services.refreshProgressStore - progressStore.beginGlobalKeywordSync(trackTotal: tracks.count) - - _ = await services.refreshCoordinator.refresh( - tracks: tracks, - in: modelContext, - analyticsTrigger: "global_keyword_sync", - progress: { completed, total, failureCount in - await progressStore.updateStep( - .keywords, - status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, - completed: completed, - total: total, - failureCount: failureCount - ) - } + let storefrontCodes = (try? StorefrontCatalog.bundledStorefrontCodes()) ?? [] + let request = AppDetailRefreshRequest( + app: AppDetailRefreshAppSnapshot( + appStoreID: 0, + bundleID: nil, + name: "Global keywords", + subtitle: nil, + sellerName: nil, + defaultPlatform: .iphone + ), + workspace: .keywords, + storefrontSelection: .all(codes: storefrontCodes), + trackIdentityKeys: tracks.map(\.identityKey), + trigger: "global_keyword_sync", + refreshRatings: false, + refreshReviews: false, + recordsRatingsReviewsRefresh: false, + popularityContextAppStoreID: services.settingsStore.popularityContextAppStoreID, + appleAdsWebSession: services.appleAdsWebSessionStore.session, + appStoreConnectCredentials: services.appStoreConnectCredentialStore.credentials ) - var metricsErrorMessage: String? - if let backgroundModelStore = services.backgroundModelStore { - let identityKeys = tracks.map(\.identityKey) - let outcomes = try? await services.keywordMetricsService.refreshMetrics( - for: identityKeys, - popularityContextAppStoreID: services.settingsStore.popularityContextAppStoreID, - webSession: services.appleAdsWebSessionStore.session, - using: backgroundModelStore, - progress: { completed, total, failureCount in - await progressStore.updateStep( - .metrics, - status: completed >= total ? (failureCount > 0 ? .failed : .completed) : .running, - completed: completed, - total: total, - failureCount: failureCount - ) - } - ) - metricsErrorMessage = outcomes?.first { $0.errorMessage != nil }?.errorMessage - } - + _ = await refreshService.refresh(request) services.markBackgroundModelStoreChanged() - progressStore.finish(error: metricsErrorMessage.map(OpenASOError.providerUnavailable)) } private static func trackKey(term: String, storefront: String, platform: AppPlatform) -> String {