Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions OpenASO.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion OpenASO/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ final class AppServices {
webSessionStore: appleAdsWebSessionStore
)
let keywordInsightsService = KeywordInsightsService()
let rankingProvider = ITunesSearchFallbackProvider(httpClient: httpClient)
let rankingProvider = SearchRankingProviderFactory.makeProduction(httpClient: httpClient)
let mcpRankingRefreshScheduler = OpenASOMCPRankingRefreshScheduler()
let refreshProgressStore = AppRefreshProgressStore()
let storefrontCatalog = StorefrontCatalog()
Expand Down
63 changes: 31 additions & 32 deletions OpenASO/App/RootSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -610,50 +610,49 @@ struct RootSidebarView: View {
}

private func deleteApp(_ trackedApp: TrackedApp) {
let trackedKeywords: [TrackedAppKeyword]
do {
trackedKeywords = try fetchTrackedKeywords(for: trackedApp)
} catch {
guard let backgroundModelStore = services.backgroundModelStore else {
currentAlert = SidebarAlertContext(
title: "Delete Failed",
message: OpenASOError.map(error).localizedDescription
message: OpenASOError.providerUnavailable(
"OpenASO’s data store is not ready."
).localizedDescription
)
return
}

let keywordCount = trackedKeywords.count
if selectedApp?.persistentModelID == trackedApp.persistentModelID {
let request = TrackedAppDeletionRequest(app: trackedApp)
let wasSelected = selectedApp?.persistentModelID == trackedApp.persistentModelID
if wasSelected {
selectedApp = trackedApps.first { $0.persistentModelID != trackedApp.persistentModelID }
}
appPendingDeletion = nil

do {
try TrackedKeywordRefreshStatusStore.deleteStatuses(
for: trackedKeywords.map(\.identityKey),
in: modelContext
)
for track in trackedKeywords {
modelContext.delete(track)
Task { @MainActor in
do {
let result = try await TrackedKeywordDeletionService.deleteApp(
request,
using: backgroundModelStore
)
if result.deletedApp {
services.analyticsService.capture(
.trackedAppRemoved(keywordCount: result.deletedTrackCount)
)
} else if wasSelected {
selectedApp = trackedApps.first { $0.appStoreID == request.appStoreID }
?? selectedApp
}
} catch {
if wasSelected {
selectedApp = trackedApps.first { $0.appStoreID == request.appStoreID }
?? selectedApp
}
currentAlert = SidebarAlertContext(
title: "Delete Failed",
message: OpenASOError.map(error).localizedDescription
)
}
modelContext.delete(trackedApp)
try modelContext.save()
services.analyticsService.capture(.trackedAppRemoved(keywordCount: keywordCount))
} catch {
currentAlert = SidebarAlertContext(
title: "Delete Failed",
message: OpenASOError.map(error).localizedDescription
)
}
}

private func fetchTrackedKeywords(for trackedApp: TrackedApp) throws -> [TrackedAppKeyword] {
let appStoreID = trackedApp.appStoreID
let descriptor = FetchDescriptor<TrackedAppKeyword>(
predicate: #Predicate { track in
track.appStoreID == appStoreID
}
)
return try modelContext.fetch(descriptor)
}
}

private struct SidebarUtilityRow: View {
Expand Down
113 changes: 113 additions & 0 deletions OpenASO/Features/AppDetail/AppDetailCSVTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,116 @@ struct AppDetailRatingsCSVExporter {
value.formatted(.number.precision(.fractionLength(0...4)))
}
}

@MainActor
struct AppDetailEstimatedDifficultyCSVExporter {
static func makeDocument(
appStoreID: Int64,
appName: String,
selectedStorefrontFilter: StorefrontFilter,
selectedPlatformFilter: PlatformFilter,
difficultyFilterRange: ClosedRange<Double>,
searchText: String,
backgroundModelStore: BackgroundModelStore?,
asOf date: Date = .now
) async throws -> CSVDocument {
guard let backgroundModelStore else {
throw OpenASOError.providerUnavailable(
"Estimated-difficulty evidence export is unavailable until the model store is ready."
)
}

let selectedStorefront: String?
switch selectedStorefrontFilter {
case .all:
selectedStorefront = nil
case .storefront(let code, _):
selectedStorefront = code
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
}

let selectedPlatformRaw: String?
switch selectedPlatformFilter {
case .all:
selectedPlatformRaw = nil
case .platform(let platform):
selectedPlatformRaw = platform.rawValue
}

let activeDifficultyRange = MetricFilterRange.difficulty.isDefault(
difficultyFilterRange
) ? nil : difficultyFilterRange
let normalizedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let items = try await backgroundModelStore.read { modelContext in
try fetchItems(
appStoreID: appStoreID,
appName: appName,
selectedStorefront: selectedStorefront,
selectedPlatformRaw: selectedPlatformRaw,
activeDifficultyRange: activeDifficultyRange,
normalizedSearch: normalizedSearch,
in: modelContext
)
}
return CSVDocument(
text: EstimatedKeywordDifficultyCSVFormat.encode(items: items, asOf: date)
)
}

nonisolated private static func fetchItems(
appStoreID: Int64,
appName: String,
selectedStorefront: String?,
selectedPlatformRaw: String?,
activeDifficultyRange: ClosedRange<Double>?,
normalizedSearch: String,
in modelContext: ModelContext
) throws -> [EstimatedKeywordDifficultyCSVItem] {
let targetAppStoreID = appStoreID
let descriptor = FetchDescriptor<TrackedAppKeyword>(
predicate: #Predicate { track in
track.appStoreID == targetAppStoreID
},
sortBy: [
SortDescriptor(\TrackedAppKeyword.term, order: .forward),
SortDescriptor(\TrackedAppKeyword.storefront, order: .forward),
SortDescriptor(\TrackedAppKeyword.platformRaw, order: .forward)
]
)
let scopedTracks = try modelContext.fetch(descriptor).filter { track in
guard selectedStorefront == nil || track.storefront == selectedStorefront else {
return false
}
guard selectedPlatformRaw == nil || track.platformRaw == selectedPlatformRaw else {
return false
}
return normalizedSearch.isEmpty || track.term.localizedStandardContains(normalizedSearch)
}
let snapshots = try EstimatedKeywordDifficultyStore.snapshots(
queryKeys: scopedTracks.map(\.queryKey),
in: modelContext
)
let tracks = scopedTracks.filter { track in
guard let activeDifficultyRange else {
return true
}
guard let score = snapshots[track.queryKey]?.score else {
return false
}
return activeDifficultyRange.contains(Double(score))
}

return tracks.map { track in
EstimatedKeywordDifficultyCSVItem(
appName: appName,
appStoreID: appStoreID,
keyword: track.term,
queryKey: track.queryKey,
storefront: track.storefront,
platformRaw: track.platformRaw,
snapshot: snapshots[track.queryKey]
)
}
}
}
2 changes: 1 addition & 1 deletion OpenASO/Features/AppDetail/AppDetailSupportTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ enum MetricFilterRange {
case .popularity:
return "Popularity"
case .difficulty:
return "Difficulty"
return "Estimated Difficulty"
case .position:
return "Position"
case .change:
Expand Down
8 changes: 8 additions & 0 deletions OpenASO/Features/AppDetail/AppDetailToolbarViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ struct AppDetailToolbarSearchField: View {
struct AppDetailImportExportToolbarMenu: View {
let exportAction: () -> Void
let exportHistoryAction: () -> Void
let exportEstimatedDifficultyEvidenceAction: () -> Void
let importAction: () -> Void
let isImportDisabled: Bool

Expand All @@ -163,6 +164,13 @@ struct AppDetailImportExportToolbarMenu: View {
Label("Export Historical Rankings CSV", systemImage: "clock.arrow.circlepath")
}

Button(action: exportEstimatedDifficultyEvidenceAction) {
Label(
"Export Estimated Difficulty Evidence CSV",
systemImage: "chart.bar.doc.horizontal"
)
}

Button(action: importAction) {
Label("Import CSV", systemImage: "square.and.arrow.down")
}
Expand Down
Loading
Loading