From 7c2ed0f0926a1bf7d18e903b4a1cfaf6790e1d73 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Fri, 13 Sep 2024 11:58:55 -0500 Subject: [PATCH 01/30] More progress --- .../WikiWrappedDataController.swift | 35 ++++++ .../Environment/WMFDataEnvironment.swift | 2 + WMFData/Sources/WMFData/Errors.swift | 9 ++ .../WMFData.xcdatamodel/contents | 9 ++ .../WMFData/Store/WMFCoreDataStore.swift | 106 ++++++++++++++++++ Wikipedia/Code/ArticleViewController.swift | 28 ++++- .../WMFAppViewController+Extensions.swift | 8 ++ 7 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift create mode 100644 WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents create mode 100644 WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift new file mode 100644 index 00000000000..d6977637328 --- /dev/null +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -0,0 +1,35 @@ +import Foundation +import CoreData + +public final class WMFWikiWrappedDataController { + + private let coreDataStore: WMFCoreDataStore + + public init(coreDataStore: WMFCoreDataStore? = WMFDataEnvironment.current.coreDataStore) throws { + + guard let coreDataStore else { + throw WMFDataControllerError.coreDataStoreUnavailable + } + + self.coreDataStore = coreDataStore + } + + public func createAndSaveViewedPage(pageTitle: String, namespaceID: Int16, project: WMFProject) async throws { + + let backgroundContext = try coreDataStore.newBackgroundContext + + try await backgroundContext.perform { + let viewedPage = try self.coreDataStore.create(entity: WMFViewedPage.self, in: backgroundContext) + + guard let viewedPage else { + return + } + + viewedPage.pageTitle = pageTitle + viewedPage.namespaceID = namespaceID + viewedPage.wmfProjectID = project.coreDataIdentifier + viewedPage.date = Date() + try backgroundContext.save() + } + } +} diff --git a/WMFData/Sources/WMFData/Environment/WMFDataEnvironment.swift b/WMFData/Sources/WMFData/Environment/WMFDataEnvironment.swift index 6abf380674c..51803216ca8 100644 --- a/WMFData/Sources/WMFData/Environment/WMFDataEnvironment.swift +++ b/WMFData/Sources/WMFData/Environment/WMFDataEnvironment.swift @@ -13,6 +13,7 @@ public final class WMFDataEnvironment: ObservableObject { public static let current = WMFDataEnvironment() public var serviceEnvironment: WMFServiceEnvironment = .production + public var appContainerURL: URL? @Published public var appData = WMFAppData(appLanguages: []) @@ -25,4 +26,5 @@ public final class WMFDataEnvironment: ObservableObject { public internal(set) var userDefaultsStore: WMFKeyValueStore? = WMFUserDefaultsStore() public var sharedCacheStore: WMFKeyValueStore? + public var coreDataStore: WMFCoreDataStore? } diff --git a/WMFData/Sources/WMFData/Errors.swift b/WMFData/Sources/WMFData/Errors.swift index 4ef129192ce..8d921944993 100644 --- a/WMFData/Sources/WMFData/Errors.swift +++ b/WMFData/Sources/WMFData/Errors.swift @@ -5,6 +5,7 @@ import Foundation public enum WMFDataControllerError: LocalizedError { case mediaWikiServiceUnavailable case basicServiceUnavailable + case coreDataStoreUnavailable case failureCreatingRequestURL case unexpectedResponse case serviceError(Error) @@ -26,6 +27,14 @@ public enum WMFUserDefaultsStoreError: Error { case failureEncodingJSON(Error) } +enum WMFCoreDataStoreError: Error { + case setupMissingAppContainerURL + case setupMissingDataModelFileURL + case setupMissingDataModel + case setupMissingPersistentContainer + case missingEntity +} + public enum WMFDonateDataControllerError: LocalizedError { case paymentsWikiResponseError(reason: String?, orderID: String?) diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents new file mode 100644 index 00000000000..aeaeb7a2115 --- /dev/null +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift new file mode 100644 index 00000000000..aa179be971d --- /dev/null +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -0,0 +1,106 @@ +import Foundation +import CoreData + +public final class WMFCoreDataStore { + + private let appContainerURL: URL + + // Will only be populated if persistent stores load correctly + private var persistentContainer: NSPersistentContainer? + + public init(appContainerURL: URL? = WMFDataEnvironment.current.appContainerURL) throws { + + guard let appContainerURL else { + throw WMFCoreDataStoreError.setupMissingAppContainerURL + } + + self.appContainerURL = appContainerURL + + let dataModelName = "WMFData" + + let databaseFileName = "WMFData.sqlite" + var databaseFileURL = appContainerURL + databaseFileURL.appendPathComponent(databaseFileName, isDirectory: false) + + guard let dataModelFileURL = Bundle.module.url(forResource: dataModelName, withExtension: "momd") else { + throw WMFCoreDataStoreError.setupMissingDataModelFileURL + } + + guard let dataModel = NSManagedObjectModel(contentsOf: dataModelFileURL) else { + throw WMFCoreDataStoreError.setupMissingDataModel + } + + let description = NSPersistentStoreDescription(url: databaseFileURL) + description.shouldAddStoreAsynchronously = true + + let container = NSPersistentContainer(name: dataModelName, managedObjectModel: dataModel) + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores { _, error in + if let error { + debugPrint("Error loading persistent stores: \(error)") + } else { + DispatchQueue.main.async { + self.persistentContainer = container + } + } + } + + self.persistentContainer = container + } + + var newBackgroundContext: NSManagedObjectContext { + get throws { + guard let persistentContainer else { + throw WMFCoreDataStoreError.setupMissingPersistentContainer + } + + return persistentContainer.newBackgroundContext() + } + } + + var viewContext: NSManagedObjectContext { + get throws { + guard let persistentContainer else { + throw WMFCoreDataStoreError.setupMissingPersistentContainer + } + + return persistentContainer.viewContext + } + } + + func create(entity: T.Type, in moc: NSManagedObjectContext) throws -> T? { + + let entityName = String(describing: entity) + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: moc) else { + throw WMFCoreDataStoreError.missingEntity + } + + let item = T(entity: entity, insertInto: moc) + return item + } + + func saveIfNeeded(moc: NSManagedObjectContext) throws { + if moc.hasChanges { + try moc.save() + } + } +} + +extension WMFProject { + var coreDataIdentifier: String { + switch self { + case .commons: + return "commons" + case .wikidata: + return "wikidata" + case .wikipedia(let language): + var identifier = "wikipedia-\(language.languageCode)" + if let variantCode = language.languageVariantCode { + identifier.append("-\(variantCode)") + } + return identifier + } + } +} diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index cad5d177ad8..0d0d9142c21 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -759,9 +759,31 @@ class ArticleViewController: ViewController, HintPresenting { guard significantlyViewedTimer == nil, !article.wasSignificantlyViewed else { return } - significantlyViewedTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false, block: { [weak self] (timer) in - self?.article.wasSignificantlyViewed = true - self?.stopSignificantlyViewedTimer() + + significantlyViewedTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false, block: { [weak self] (timer) in + + guard let self else { + return + } + + self.article.wasSignificantlyViewed = true + + if let title = self.articleURL.wmf_title, + let namespace = self.articleURL.namespace, + let siteURL = self.articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL), + let wmfProject = project.wmfProject { + Task { + do { + let wikiwrappedDataController = try WMFWikiWrappedDataController() + try await wikiwrappedDataController.createAndSaveViewedPage(pageTitle: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) + } catch let error { + DDLogError("Error saving viewed page: \(error)") + } + } + } + + self.stopSignificantlyViewedTimer() }) } diff --git a/Wikipedia/Code/WMFAppViewController+Extensions.swift b/Wikipedia/Code/WMFAppViewController+Extensions.swift index 94f211847af..916df4b8e9c 100644 --- a/Wikipedia/Code/WMFAppViewController+Extensions.swift +++ b/Wikipedia/Code/WMFAppViewController+Extensions.swift @@ -3,6 +3,7 @@ import WMF import SwiftUI import WMFComponents import WMFData +import CocoaLumberjackSwift extension Notification.Name { static let showErrorBanner = Notification.Name("WMFShowErrorBanner") @@ -570,6 +571,13 @@ extension WMFAppViewController { WMFDataEnvironment.current.serviceEnvironment = .production } + WMFDataEnvironment.current.appContainerURL = FileManager.default.wmf_containerURL() + do { + WMFDataEnvironment.current.coreDataStore = try WMFCoreDataStore() + } catch let error { + DDLogError("Error setting up WMFCoreDataStore: \(error)") + } + WMFDataEnvironment.current.userAgentUtility = { return WikipediaAppUtils.versionedUserAgent() } From 5b22dc05e0a7171f6916971b9a2b519d330b1afd Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Fri, 13 Sep 2024 17:08:30 -0500 Subject: [PATCH 02/30] Split off into PageView and Page --- .../WikiWrappedDataController.swift | 15 +++++-------- .../Sources/WMFData/Models/Shared/Page.swift | 18 +++++++++++++++ .../WMFData/Models/Shared/PageView.swift | 14 ++++++++++++ .../WMFData.xcdatamodel/contents | 16 +++++++++----- .../WMFData/Store/WMFCoreDataStore.swift | 22 ++++++++++++++++++- Wikipedia/Code/ArticleViewController.swift | 2 +- 6 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 WMFData/Sources/WMFData/Models/Shared/Page.swift create mode 100644 WMFData/Sources/WMFData/Models/Shared/PageView.swift diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index d6977637328..8f6c5ad7f31 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -14,21 +14,18 @@ public final class WMFWikiWrappedDataController { self.coreDataStore = coreDataStore } - public func createAndSaveViewedPage(pageTitle: String, namespaceID: Int16, project: WMFProject) async throws { + public func addPageView(title: String, namespaceID: Int16, project: WMFProject) async throws { let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { - let viewedPage = try self.coreDataStore.create(entity: WMFViewedPage.self, in: backgroundContext) - guard let viewedPage else { - return - } + let predicate = NSPredicate(format: "title == %@ && namespaceID == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) + let page = try self.coreDataStore.fetchOrCreate(entity: CDPage.self, predicate: predicate, in: backgroundContext) - viewedPage.pageTitle = pageTitle - viewedPage.namespaceID = namespaceID - viewedPage.wmfProjectID = project.coreDataIdentifier - viewedPage.date = Date() + let viewedPage = try self.coreDataStore.create(entity: CDPageView.self, in: backgroundContext) + viewedPage.page = page + viewedPage.timestamp = Date() try backgroundContext.save() } } diff --git a/WMFData/Sources/WMFData/Models/Shared/Page.swift b/WMFData/Sources/WMFData/Models/Shared/Page.swift new file mode 100644 index 00000000000..cd5d2e9bd2b --- /dev/null +++ b/WMFData/Sources/WMFData/Models/Shared/Page.swift @@ -0,0 +1,18 @@ +// import Foundation +// import SwiftData +// +// @available(iOS 17, *) +// @Model +// public final class Page { +// var namespace: Int +// var projectID: String +// var title: String +// var pageViews: [PageView] +// +// init(namespace: Int, projectID: String, title: String) { +// self.namespace = namespace +// self.projectID = projectID +// self.title = title +// self.pageViews = [] +// } +// } diff --git a/WMFData/Sources/WMFData/Models/Shared/PageView.swift b/WMFData/Sources/WMFData/Models/Shared/PageView.swift new file mode 100644 index 00000000000..f3c3b2033cb --- /dev/null +++ b/WMFData/Sources/WMFData/Models/Shared/PageView.swift @@ -0,0 +1,14 @@ +// import Foundation +// import SwiftData +// +// @available(iOS 17, *) +// @Model +// final class PageView { +// var date: Date +// var page: Page +// +// init(date: Date, page: Page) { +// self.date = date +// self.page = page +// } +// } diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index aeaeb7a2115..159075590f6 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -1,9 +1,13 @@ - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index aa179be971d..0e24708b0e6 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -69,7 +69,27 @@ public final class WMFCoreDataStore { } } - func create(entity: T.Type, in moc: NSManagedObjectContext) throws -> T? { + func fetchOrCreate(entity: T.Type, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { + + guard let existing = try fetch(entity: entity, predicate: predicate, in: moc) else { + return try create(entity: entity, in: moc) + } + + return existing + } + + func fetch(entity: T.Type, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { + + let entityName = String(describing: entity) + + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = predicate + fetchRequest.fetchLimit = 1 + let results: [T] = try moc.fetch(fetchRequest) + return results.first + } + + func create(entity: T.Type, in moc: NSManagedObjectContext) throws -> T { let entityName = String(describing: entity) diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index 0d0d9142c21..862840c7c09 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -776,7 +776,7 @@ class ArticleViewController: ViewController, HintPresenting { Task { do { let wikiwrappedDataController = try WMFWikiWrappedDataController() - try await wikiwrappedDataController.createAndSaveViewedPage(pageTitle: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) + try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) } catch let error { DDLogError("Error saving viewed page: \(error)") } From b8e80ed9f3f75db32f8aa020fd21ff70f4d4b1e9 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Fri, 13 Sep 2024 17:10:15 -0500 Subject: [PATCH 03/30] Remove usedWithSwiftData --- .../Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index 159075590f6..fef427cd688 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + From 186ab5ba640620350a90af6969ccc0f237675b4a Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 13:54:09 -0500 Subject: [PATCH 04/30] Progress from playing around with SwiftData --- .../Components/Watchlist/PageViewsView.swift | 159 ++++++++++++++++++ .../WikiWrappedDataController.swift | 95 ++++++++++- .../Sources/WMFData/Models/Shared/Page.swift | 18 -- .../WMFData/Models/Shared/PageView.swift | 14 -- .../WMFData.xcdatamodel/contents | 8 +- .../WMFData/Store/WMFCoreDataStore.swift | 48 ++++-- Wikipedia/Code/ArticleViewController.swift | 2 +- Wikipedia/Code/ExploreViewController.swift | 37 ++-- 8 files changed, 313 insertions(+), 68 deletions(-) create mode 100644 WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift delete mode 100644 WMFData/Sources/WMFData/Models/Shared/Page.swift delete mode 100644 WMFData/Sources/WMFData/Models/Shared/PageView.swift diff --git a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift new file mode 100644 index 00000000000..398b5b30a70 --- /dev/null +++ b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import CoreData +import WMFData +import SwiftData + +// Begin WMFData abstraction + + public struct PageViewsView: View { + + @State var pageViews: [WMFPageView] + + public init(pageViews: [WMFPageView] = []) { + self.pageViews = pageViews + } + + public var body: some View { + List(pageViews) { pageView in + Text(pageView.page.title) + .swipeActions { + Button("Delete", systemImage: "trash", role: .destructive) { + Task { + do { + try await WMFWikiWrappedDataController().deletePageView(pageView: pageView) + } catch { + print(error) + } + } + } + } + } + .onAppear { + Task { + do { + let pageViews = try WMFWikiWrappedDataController().fetchPageViews() + self.pageViews = pageViews + } catch { + print(error) + } + } + } + } + } + +// End: WMFData abstraction + +// Begin: CoreData + +// public struct PageViewsViewCoreData: View { +// +// let moc: NSManagedObjectContext +// +// public init(moc: NSManagedObjectContext) { +// self.moc = moc +// } +// +// public var body: some View { +// PageViewsViewList() +// .environment(\.managedObjectContext, moc) +// } +// } +// +// struct PageViewsViewList: View { +// +// @FetchRequest(sortDescriptors: []) var pageViews: FetchedResults +// @Environment(\.managedObjectContext) var moc +// +// var body: some View { +// List(pageViews) { pageView in +// Text(pageView.page?.title ?? "") +// .swipeActions { +// Button("Delete", systemImage: "trash", role: .destructive) { +// moc.delete(pageView) +// try? moc.save() +// } +// } +// } +// } +// } + +// End: CoreData + +// Begin: SwiftData + +// @available(iOS 17, *) +// @Model +// public final class WMFPage { +// var namespace: Int +// var projectID: String +// public var title: String +// var pageViews: [WMFPageView] +// +// init(namespace: Int, projectID: String, title: String) { +// self.namespace = namespace +// self.projectID = projectID +// self.title = title +// self.pageViews = [] +// } +// } +// +// @available(iOS 17, *) +// @Model +// public final class WMFPageView { +// public var timestamp: Date +// public var page: WMFPage +// +// init(timestamp: Date, page: WMFPage) { +// self.timestamp = timestamp +// self.page = page +// } +// } +// +// @available(iOS 17, *) +// public struct PageViewsViewSwiftData: View { +// +// @State var pageViews: [WMFPageView] +// private let modelContainer: ModelContainer? +// +// public init(pageViews: [WMFPageView]) { +// self.pageViews = pageViews +// +// guard let appContainerURL = WMFDataEnvironment.current.appContainerURL else { +// self.modelContainer = nil +// return +// } +// +// let url = appContainerURL.appendingPathComponent("WMFData.sqlite") +// +// guard let modelContainer = try? ModelContainer(for: WMFPageView.self, configurations: ModelConfiguration(url: url)) else { +// self.modelContainer = nil +// return +// } +// +// self.modelContainer = modelContainer +// } +// +// +// public var body: some View { +// List(pageViews) { pageView in +// Text(pageView.page.title) +// .swipeActions { +// Button("Delete", systemImage: "trash", role: .destructive) { +// modelContainer?.mainContext.delete(pageView) +// } +// } +// } +// .onAppear { +// Task { @MainActor in +// +// let fetchDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\WMFPageView.timestamp, order: .forward)]) +// +// if let pageViews = try? modelContainer?.mainContext.fetch(fetchDescriptor) { +// self.pageViews = pageViews +// } +// } +// } +// } +// } + +// End: SwiftData diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 8f6c5ad7f31..de651aff706 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -1,6 +1,33 @@ import Foundation import CoreData + public final class WMFPage { + let namespaceID: Int + let projectID: String + public let title: String + let pageViews: [WMFPageView] + + init(namespaceID: Int, projectID: String, title: String, pageViews: [WMFPageView] = []) { + self.namespaceID = namespaceID + self.projectID = projectID + self.title = title + self.pageViews = pageViews + } + } + +public final class WMFPageView: Identifiable { + public var id: Date { + return timestamp + } + public let timestamp: Date + public let page: WMFPage + + init(timestamp: Date, page: WMFPage) { + self.timestamp = timestamp + self.page = page + } + } + public final class WMFWikiWrappedDataController { private let coreDataStore: WMFCoreDataStore @@ -18,15 +45,73 @@ public final class WMFWikiWrappedDataController { let backgroundContext = try coreDataStore.newBackgroundContext - try await backgroundContext.perform { + try await backgroundContext.perform { [weak self] in + + guard let self else { return } - let predicate = NSPredicate(format: "title == %@ && namespaceID == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) - let page = try self.coreDataStore.fetchOrCreate(entity: CDPage.self, predicate: predicate, in: backgroundContext) + let predicate = NSPredicate(format: "title == %@ && namespace == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) + let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) + page?.title = title + page?.namespace = namespaceID + page?.projectID = project.coreDataIdentifier - let viewedPage = try self.coreDataStore.create(entity: CDPageView.self, in: backgroundContext) + let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) viewedPage.page = page viewedPage.timestamp = Date() - try backgroundContext.save() + + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + } + + try coreDataStore.pruneTransactionHistory() + } + + public func fetchPageViews() throws -> [WMFPageView] { + + let viewContext = try coreDataStore.viewContext + let results: [WMFPageView] = try viewContext.performAndWait { + guard let cdPageViews = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: viewContext) else { + return [] + } + + var pageViews: [WMFPageView] = [] + + for cdPageView in cdPageViews { + guard let timestamp = cdPageView.timestamp, + let cdPage = cdPageView.page else { + continue + } + + guard let projectID = cdPage.projectID, + let title = cdPage.title else { + continue + } + + let page = WMFPage(namespaceID: Int(cdPage.namespace), projectID: projectID, title: title, pageViews: []) + + pageViews.append(WMFPageView(timestamp: timestamp, page: page)) + } + + return pageViews + } + + return results + } + + public func deletePageView(pageView: WMFPageView) async throws { + let backgroundContext = try coreDataStore.newBackgroundContext + + try await backgroundContext.perform { [weak self] in + + guard let self else { return } + + let predicate = NSPredicate(format: "timestamp == %@", argumentArray: [pageView.timestamp]) + + guard let page = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: predicate, fetchLimit: 1, in: backgroundContext)?.first else { + return + } + + backgroundContext.delete(page) + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) } } } diff --git a/WMFData/Sources/WMFData/Models/Shared/Page.swift b/WMFData/Sources/WMFData/Models/Shared/Page.swift deleted file mode 100644 index cd5d2e9bd2b..00000000000 --- a/WMFData/Sources/WMFData/Models/Shared/Page.swift +++ /dev/null @@ -1,18 +0,0 @@ -// import Foundation -// import SwiftData -// -// @available(iOS 17, *) -// @Model -// public final class Page { -// var namespace: Int -// var projectID: String -// var title: String -// var pageViews: [PageView] -// -// init(namespace: Int, projectID: String, title: String) { -// self.namespace = namespace -// self.projectID = projectID -// self.title = title -// self.pageViews = [] -// } -// } diff --git a/WMFData/Sources/WMFData/Models/Shared/PageView.swift b/WMFData/Sources/WMFData/Models/Shared/PageView.swift deleted file mode 100644 index f3c3b2033cb..00000000000 --- a/WMFData/Sources/WMFData/Models/Shared/PageView.swift +++ /dev/null @@ -1,14 +0,0 @@ -// import Foundation -// import SwiftData -// -// @available(iOS 17, *) -// @Model -// final class PageView { -// var date: Date -// var page: Page -// -// init(date: Date, page: Page) { -// self.date = date -// self.page = page -// } -// } diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index fef427cd688..cb499d9242a 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + - + - + \ No newline at end of file diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index 0e24708b0e6..6f52a22f2f4 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -32,15 +32,24 @@ public final class WMFCoreDataStore { let description = NSPersistentStoreDescription(url: databaseFileURL) description.shouldAddStoreAsynchronously = true + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) let container = NSPersistentContainer(name: dataModelName, managedObjectModel: dataModel) container.persistentStoreDescriptions = [description] + for description in container.persistentStoreDescriptions { + for (key, value) in description.options { + print("\(key): \(value)") + } + } container.loadPersistentStores { _, error in if let error { debugPrint("Error loading persistent stores: \(error)") } else { DispatchQueue.main.async { + container.viewContext.automaticallyMergesChangesFromParent = true + container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + self.persistentContainer = container } } @@ -59,7 +68,7 @@ public final class WMFCoreDataStore { } } - var viewContext: NSManagedObjectContext { + public var viewContext: NSManagedObjectContext { get throws { guard let persistentContainer else { throw WMFCoreDataStoreError.setupMissingPersistentContainer @@ -69,29 +78,27 @@ public final class WMFCoreDataStore { } } - func fetchOrCreate(entity: T.Type, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { + func fetchOrCreate(entityType: T.Type, entityName: String, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { - guard let existing = try fetch(entity: entity, predicate: predicate, in: moc) else { - return try create(entity: entity, in: moc) + guard let existing: [T] = try fetch(entityType: entityType, entityName: entityName, predicate: predicate, fetchLimit: 1, in: moc), + !existing.isEmpty else { + return try create(entityType: entityType, entityName: entityName, in: moc) } - return existing + return existing.first } - func fetch(entity: T.Type, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { - - let entityName = String(describing: entity) + func fetch(entityType: T.Type, entityName: String, predicate: NSPredicate?, fetchLimit: Int?, in moc: NSManagedObjectContext) throws -> [T]? { let fetchRequest = NSFetchRequest(entityName: entityName) fetchRequest.predicate = predicate - fetchRequest.fetchLimit = 1 - let results: [T] = try moc.fetch(fetchRequest) - return results.first + if let fetchLimit { + fetchRequest.fetchLimit = fetchLimit + } + return try moc.fetch(fetchRequest) } - func create(entity: T.Type, in moc: NSManagedObjectContext) throws -> T { - - let entityName = String(describing: entity) + func create(entityType: T.Type, entityName: String, in moc: NSManagedObjectContext) throws -> T { guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: moc) else { throw WMFCoreDataStoreError.missingEntity @@ -106,6 +113,19 @@ public final class WMFCoreDataStore { try moc.save() } } + + func pruneTransactionHistory() throws { + + guard let sevenDaysAgo = Calendar.current.date(byAdding: .day, + value: -7, + to: Date()) else { + return + } + + let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgo) + let backgroundContext = try newBackgroundContext + try backgroundContext.execute(deleteHistoryRequest) + } } extension WMFProject { diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index 862840c7c09..032ca0cfe3e 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -756,7 +756,7 @@ class ArticleViewController: ViewController, HintPresenting { var significantlyViewedTimer: Timer? func startSignificantlyViewedTimer() { - guard significantlyViewedTimer == nil, !article.wasSignificantlyViewed else { + guard significantlyViewedTimer == nil else { return } diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index 1ec5411a7cb..5fd433b1e7a 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -203,17 +203,7 @@ class ExploreViewController: ColumnarCollectionViewController, ExploreCardViewCo @objc func userDidTapProfile() { DonateFunnel.shared.logSettingsDidTapSettingsIcon() - // settingsPresentationDelegate?.userDidTapProfile(from: self) - let profileView = WMFProfileView(isLoggedIn: true) - let hostingController = UIHostingController(rootView: profileView) - hostingController.modalPresentationStyle = .pageSheet - - if let sheetPresentationController = hostingController.sheetPresentationController { - sheetPresentationController.detents = [.large()] - sheetPresentationController.prefersGrabberVisible = true - } - - present(hostingController, animated: true, completion: nil) + settingsPresentationDelegate?.userDidTapProfile(from: self) } open override func refresh() { @@ -1231,7 +1221,30 @@ extension ExploreViewController: ExploreCardCollectionViewCellDelegate { extension ExploreViewController { @objc func userDidTapNotificationsCenter() { - notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) + // notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) + + if #available(iOS 17, *) { + +// guard let moc = try? WMFDataEnvironment.current.coreDataStore?.viewContext else { +// return +// } +// let pageViews = PageViewsViewCoreData(moc: moc) + + let pageViews = PageViewsView(pageViews: []) + // let pageViews = PageViewsViewSwiftData(pageViews: []) + + let hostingController = UIHostingController(rootView: pageViews) + hostingController.modalPresentationStyle = .pageSheet + + if let sheetPresentationController = hostingController.sheetPresentationController { + sheetPresentationController.detents = [.large()] + sheetPresentationController.prefersGrabberVisible = true + } + + present(hostingController, animated: true, completion: nil) + } else { + // Fallback on earlier versions + } } @objc func pushNotificationBannerDidDisplayInForeground(_ notification: Notification) { From ff5a7086fc40bf8702e62626a0c3081be1499992 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 14:19:38 -0500 Subject: [PATCH 05/30] Add description options from MWKDataStore, rename namespace attribute --- .../WikiWrapped/WikiWrappedDataController.swift | 6 +++--- .../WMFData.xcdatamodeld/WMFData.xcdatamodel/contents | 2 +- WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index de651aff706..f97735b457e 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -49,10 +49,10 @@ public final class WMFWikiWrappedDataController { guard let self else { return } - let predicate = NSPredicate(format: "title == %@ && namespace == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) + let predicate = NSPredicate(format: "title == %@ && namespaceID == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) page?.title = title - page?.namespace = namespaceID + page?.namespaceID = namespaceID page?.projectID = project.coreDataIdentifier let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) @@ -86,7 +86,7 @@ public final class WMFWikiWrappedDataController { continue } - let page = WMFPage(namespaceID: Int(cdPage.namespace), projectID: projectID, title: title, pageViews: []) + let page = WMFPage(namespaceID: Int(cdPage.namespaceID), projectID: projectID, title: title, pageViews: []) pageViews.append(WMFPageView(timestamp: timestamp, page: page)) } diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index cb499d9242a..dc3ab545011 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -1,7 +1,7 @@ - + diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index 6f52a22f2f4..a63bf5402fa 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -32,6 +32,8 @@ public final class WMFCoreDataStore { let description = NSPersistentStoreDescription(url: databaseFileURL) description.shouldAddStoreAsynchronously = true + description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) + description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) let container = NSPersistentContainer(name: dataModelName, managedObjectModel: dataModel) From 3f192f7fa03723c54e3a447a52c96ffd5154fd3c Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 15:17:56 -0500 Subject: [PATCH 06/30] Add fetch indexes, timestamps, improve delimiter --- .../WikiWrapped/WikiWrappedDataController.swift | 10 +++++++--- .../WMFData.xcdatamodel/contents | 14 ++++++++++++-- .../Sources/WMFData/Store/WMFCoreDataStore.swift | 10 ++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index f97735b457e..76ec4ab1c16 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -43,21 +43,25 @@ public final class WMFWikiWrappedDataController { public func addPageView(title: String, namespaceID: Int16, project: WMFProject) async throws { + let coreDataTitle = title.coreDataTitle + let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { [weak self] in guard let self else { return } - let predicate = NSPredicate(format: "title == %@ && namespaceID == %@ && projectID == %@", argumentArray: [title, namespaceID, project.coreDataIdentifier]) + let currentDate = Date() + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [project.coreDataIdentifier, namespaceID, coreDataTitle]) let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) - page?.title = title + page?.title = coreDataTitle page?.namespaceID = namespaceID page?.projectID = project.coreDataIdentifier + page?.timestamp = currentDate let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) viewedPage.page = page - viewedPage.timestamp = Date() + viewedPage.timestamp = currentDate try self.coreDataStore.saveIfNeeded(moc: backgroundContext) } diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index dc3ab545011..ba69e1259d8 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -3,11 +3,21 @@ + - + + + + + + + + + + - + \ No newline at end of file diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index a63bf5402fa..f663ccea73d 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -138,11 +138,17 @@ extension WMFProject { case .wikidata: return "wikidata" case .wikipedia(let language): - var identifier = "wikipedia-\(language.languageCode)" + var identifier = "wikipedia~\(language.languageCode)" if let variantCode = language.languageVariantCode { - identifier.append("-\(variantCode)") + identifier.append("~\(variantCode)") } return identifier } } } + +extension String { + var coreDataTitle: String { + return self.spacesToUnderscores.precomposedStringWithCanonicalMapping + } +} From 82a46dd300dc42d36f5fd6a20ce76e1595db1004 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 15:19:28 -0500 Subject: [PATCH 07/30] More renames --- .../WikiWrapped/WikiWrappedDataController.swift | 2 +- WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 76ec4ab1c16..a12fcd2c71a 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -43,7 +43,7 @@ public final class WMFWikiWrappedDataController { public func addPageView(title: String, namespaceID: Int16, project: WMFProject) async throws { - let coreDataTitle = title.coreDataTitle + let coreDataTitle = title.normalizedForCoreData let backgroundContext = try coreDataStore.newBackgroundContext diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index f663ccea73d..665d84f6ff8 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -148,7 +148,7 @@ extension WMFProject { } extension String { - var coreDataTitle: String { + var normalizedForCoreData: String { return self.spacesToUnderscores.precomposedStringWithCanonicalMapping } } From 7e36f1bff50d3a2b3637a0e7176fa7b6b06df02e Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 15:33:38 -0500 Subject: [PATCH 08/30] Explore pushing on article --- .../Components/Watchlist/PageViewsView.swift | 12 +++++-- .../WikiWrappedDataController.swift | 4 +-- Wikipedia/Code/ExploreViewController.swift | 32 ++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift index 398b5b30a70..3ebd5c6a11a 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift @@ -5,17 +5,25 @@ import SwiftData // Begin WMFData abstraction +public protocol PageViewsViewDelegate: AnyObject { + func didTapPageView(pageView: WMFPageView) +} + public struct PageViewsView: View { @State var pageViews: [WMFPageView] + weak var delegate: PageViewsViewDelegate? - public init(pageViews: [WMFPageView] = []) { + public init(pageViews: [WMFPageView] = [], delegate: PageViewsViewDelegate?) { self.pageViews = pageViews + self.delegate = delegate } public var body: some View { List(pageViews) { pageView in - Text(pageView.page.title) + Button(pageView.page.title) { + delegate?.didTapPageView(pageView: pageView) + } .swipeActions { Button("Delete", systemImage: "trash", role: .destructive) { Task { diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index a12fcd2c71a..70ba0987530 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -2,8 +2,8 @@ import Foundation import CoreData public final class WMFPage { - let namespaceID: Int - let projectID: String + public let namespaceID: Int + public let projectID: String public let title: String let pageViews: [WMFPageView] diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index 5fd433b1e7a..c0927c69981 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -1230,7 +1230,7 @@ extension ExploreViewController { // } // let pageViews = PageViewsViewCoreData(moc: moc) - let pageViews = PageViewsView(pageViews: []) + let pageViews = PageViewsView(pageViews: [], delegate: self) // let pageViews = PageViewsViewSwiftData(pageViews: []) let hostingController = UIHostingController(rootView: pageViews) @@ -1961,3 +1961,33 @@ extension ExploreViewController: WMFAltTextPreviewDelegate { } } + +extension ExploreViewController: PageViewsViewDelegate { + func didTapPageView(pageView: WMFData.WMFPageView) { + + dismiss(animated: true) { + let projectElements = pageView.page.projectID.split(separator: "~") + + guard projectElements.count >= 2 else { + return + } + + let title = pageView.page.title + + switch projectElements[0] { + case "wikipedia": + let languageCode = projectElements[1] + let url = URL(string: "https://\(languageCode).wikipedia.org/wiki/\(title)")! + if let articleVC = ArticleViewController(articleURL: url, dataStore: self.dataStore, theme: self.theme) { + self.navigationController?.pushViewController(articleVC, animated: true) + } + default: + break + } + } + + + } + + +} From 5c238d77bb14de2cfbde26558b67961c86414f69 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 16:26:38 -0500 Subject: [PATCH 09/30] Add fetching by group --- .../Components/Watchlist/PageViewsView.swift | 43 +++++----- .../WikiWrappedDataController.swift | 86 ++++++++++--------- WMFData/Sources/WMFData/Errors.swift | 1 + .../WMFData/Store/WMFCoreDataStore.swift | 22 +++++ Wikipedia/Code/ExploreViewController.swift | 12 +-- 5 files changed, 97 insertions(+), 67 deletions(-) diff --git a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift index 3ebd5c6a11a..87183b8d067 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift @@ -6,41 +6,44 @@ import SwiftData // Begin WMFData abstraction public protocol PageViewsViewDelegate: AnyObject { - func didTapPageView(pageView: WMFPageView) + func didTapPage(page: WMFPage) } public struct PageViewsView: View { - @State var pageViews: [WMFPageView] + @State var pageViewCounts: [WMFPageViewCount] weak var delegate: PageViewsViewDelegate? - public init(pageViews: [WMFPageView] = [], delegate: PageViewsViewDelegate?) { - self.pageViews = pageViews + public init(pageViewCounts: [WMFPageViewCount] = [], delegate: PageViewsViewDelegate?) { + self.pageViewCounts = pageViewCounts self.delegate = delegate } public var body: some View { - List(pageViews) { pageView in - Button(pageView.page.title) { - delegate?.didTapPageView(pageView: pageView) + List(pageViewCounts) { pageViewCount in + HStack { + Button(pageViewCount.page.title) { + delegate?.didTapPage(page: pageViewCount.page) + } + Text(String(pageViewCount.count)) } - .swipeActions { - Button("Delete", systemImage: "trash", role: .destructive) { - Task { - do { - try await WMFWikiWrappedDataController().deletePageView(pageView: pageView) - } catch { - print(error) - } - } - } - } +// .swipeActions { +// Button("Delete", systemImage: "trash", role: .destructive) { +// Task { +// do { +// try await WMFWikiWrappedDataController().deletePageView(pageView: pageViewCount.page) +// } catch { +// print(error) +// } +// } +// } +// } } .onAppear { Task { do { - let pageViews = try WMFWikiWrappedDataController().fetchPageViews() - self.pageViews = pageViews + let pageViewsCounts = try WMFWikiWrappedDataController().fetchPageViewCounts() + self.pageViewCounts = pageViewsCounts } catch { print(error) } diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 70ba0987530..476dbbae799 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -15,10 +15,7 @@ import CoreData } } -public final class WMFPageView: Identifiable { - public var id: Date { - return timestamp - } +public final class WMFPageView { public let timestamp: Date public let page: WMFPage @@ -28,6 +25,21 @@ public final class WMFPageView: Identifiable { } } +public final class WMFPageViewCount: Identifiable { + + public var id: String { + return "\(page.projectID)~\(page.namespaceID)~\(page.title)" + } + + public let page: WMFPage + public let count: Int + + init(page: WMFPage, count: Int) { + self.page = page + self.count = count + } + } + public final class WMFWikiWrappedDataController { private let coreDataStore: WMFCoreDataStore @@ -69,53 +81,49 @@ public final class WMFWikiWrappedDataController { try coreDataStore.pruneTransactionHistory() } - public func fetchPageViews() throws -> [WMFPageView] { + public func fetchPageViewCounts() throws -> [WMFPageViewCount] { let viewContext = try coreDataStore.viewContext - let results: [WMFPageView] = try viewContext.performAndWait { - guard let cdPageViews = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: viewContext) else { - return [] - } - - var pageViews: [WMFPageView] = [] - - for cdPageView in cdPageViews { - guard let timestamp = cdPageView.timestamp, - let cdPage = cdPageView.page else { + let results: [WMFPageViewCount] = try viewContext.performAndWait { + let pageViewsDict = try self.coreDataStore.fetchGrouped(entityName: "WMFPageView", predicate: nil, propertyToCount: "page", propertiesToGroupBy: ["page"], propertiesToFetch: ["page"], in: viewContext) + var pageViewCounts: [WMFPageViewCount] = [] + for dict in pageViewsDict { + + guard let objectID = dict["page"] as? NSManagedObjectID, + let count = dict["count"] as? Int else { continue } - guard let projectID = cdPage.projectID, - let title = cdPage.title else { + guard let page = viewContext.object(with: objectID) as? CDPage, + let projectID = page.projectID, let title = page.title else { continue } - let page = WMFPage(namespaceID: Int(cdPage.namespaceID), projectID: projectID, title: title, pageViews: []) - - pageViews.append(WMFPageView(timestamp: timestamp, page: page)) + let namespaceID = page.namespaceID + + pageViewCounts.append(WMFPageViewCount(page: WMFPage(namespaceID: Int(namespaceID), projectID: projectID, title: title), count: count)) } - - return pageViews + return pageViewCounts } return results } - public func deletePageView(pageView: WMFPageView) async throws { - let backgroundContext = try coreDataStore.newBackgroundContext - - try await backgroundContext.perform { [weak self] in - - guard let self else { return } - - let predicate = NSPredicate(format: "timestamp == %@", argumentArray: [pageView.timestamp]) - - guard let page = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: predicate, fetchLimit: 1, in: backgroundContext)?.first else { - return - } - - backgroundContext.delete(page) - try self.coreDataStore.saveIfNeeded(moc: backgroundContext) - } - } +// public func deletePageView(pageView: WMFPageView) async throws { +// let backgroundContext = try coreDataStore.newBackgroundContext +// +// try await backgroundContext.perform { [weak self] in +// +// guard let self else { return } +// +// let predicate = NSPredicate(format: "timestamp == %@", argumentArray: [pageView.timestamp]) +// +// guard let page = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: predicate, fetchLimit: 1, in: backgroundContext)?.first else { +// return +// } +// +// backgroundContext.delete(page) +// try self.coreDataStore.saveIfNeeded(moc: backgroundContext) +// } +// } } diff --git a/WMFData/Sources/WMFData/Errors.swift b/WMFData/Sources/WMFData/Errors.swift index 8d921944993..cb63fc091ec 100644 --- a/WMFData/Sources/WMFData/Errors.swift +++ b/WMFData/Sources/WMFData/Errors.swift @@ -33,6 +33,7 @@ enum WMFCoreDataStoreError: Error { case setupMissingDataModel case setupMissingPersistentContainer case missingEntity + case unexpectedFetchGroupResult } public enum WMFDonateDataControllerError: LocalizedError { diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index 665d84f6ff8..29ce9eaaa0c 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -100,6 +100,28 @@ public final class WMFCoreDataStore { return try moc.fetch(fetchRequest) } + func fetchGrouped(entityName: String, predicate: NSPredicate?, propertyToCount: String, propertiesToGroupBy: [String], propertiesToFetch: [String], in moc: NSManagedObjectContext) throws -> [[String: Any]] { + + let keypathExp = NSExpression(forKeyPath: propertyToCount) + let expression = NSExpression(forFunction: "count:", arguments: [keypathExp]) + + let countDesc = NSExpressionDescription() + countDesc.expression = expression + countDesc.name = "count" + countDesc.expressionResultType = .integer64AttributeType + + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = predicate + fetchRequest.propertiesToGroupBy = propertiesToGroupBy + fetchRequest.propertiesToFetch = propertiesToFetch + [countDesc] + fetchRequest.resultType = .dictionaryResultType + fetchRequest.returnsDistinctResults = true + guard let result = try moc.fetch(fetchRequest) as? [[String: Any]] else { + throw WMFCoreDataStoreError.unexpectedFetchGroupResult + } + return result + } + func create(entityType: T.Type, entityName: String, in moc: NSManagedObjectContext) throws -> T { guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: moc) else { diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index c0927c69981..ca689ae4699 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -1230,7 +1230,7 @@ extension ExploreViewController { // } // let pageViews = PageViewsViewCoreData(moc: moc) - let pageViews = PageViewsView(pageViews: [], delegate: self) + let pageViews = PageViewsView(pageViewCounts: [], delegate: self) // let pageViews = PageViewsViewSwiftData(pageViews: []) let hostingController = UIHostingController(rootView: pageViews) @@ -1963,16 +1963,16 @@ extension ExploreViewController: WMFAltTextPreviewDelegate { } extension ExploreViewController: PageViewsViewDelegate { - func didTapPageView(pageView: WMFData.WMFPageView) { + func didTapPage(page: WMFData.WMFPage) { dismiss(animated: true) { - let projectElements = pageView.page.projectID.split(separator: "~") + let projectElements = page.projectID.split(separator: "~") guard projectElements.count >= 2 else { return } - let title = pageView.page.title + let title = page.title switch projectElements[0] { case "wikipedia": @@ -1985,9 +1985,5 @@ extension ExploreViewController: PageViewsViewDelegate { break } } - - } - - } From 5a1b3fa52af689cf233ddc6d8dacb59be9795872 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 17:13:33 -0500 Subject: [PATCH 10/30] Move cleanup into housekeeping methods --- .../WikiWrappedDataController.swift | 20 ------- .../WMFData/Store/WMFCoreDataStore.swift | 56 +++++++++++++++---- .../WMFAppViewController+Extensions.swift | 12 ++++ Wikipedia/Code/WMFAppViewController.m | 3 + 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 476dbbae799..b832915c397 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -77,8 +77,6 @@ public final class WMFWikiWrappedDataController { try self.coreDataStore.saveIfNeeded(moc: backgroundContext) } - - try coreDataStore.pruneTransactionHistory() } public func fetchPageViewCounts() throws -> [WMFPageViewCount] { @@ -108,22 +106,4 @@ public final class WMFWikiWrappedDataController { return results } - -// public func deletePageView(pageView: WMFPageView) async throws { -// let backgroundContext = try coreDataStore.newBackgroundContext -// -// try await backgroundContext.perform { [weak self] in -// -// guard let self else { return } -// -// let predicate = NSPredicate(format: "timestamp == %@", argumentArray: [pageView.timestamp]) -// -// guard let page = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: predicate, fetchLimit: 1, in: backgroundContext)?.first else { -// return -// } -// -// backgroundContext.delete(page) -// try self.coreDataStore.saveIfNeeded(moc: backgroundContext) -// } -// } } diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index 29ce9eaaa0c..ebfbcd6bbce 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -38,12 +38,7 @@ public final class WMFCoreDataStore { let container = NSPersistentContainer(name: dataModelName, managedObjectModel: dataModel) container.persistentStoreDescriptions = [description] - - for description in container.persistentStoreDescriptions { - for (key, value) in description.options { - print("\(key): \(value)") - } - } + container.loadPersistentStores { _, error in if let error { debugPrint("Error loading persistent stores: \(error)") @@ -90,13 +85,18 @@ public final class WMFCoreDataStore { return existing.first } - func fetch(entityType: T.Type, entityName: String, predicate: NSPredicate?, fetchLimit: Int?, in moc: NSManagedObjectContext) throws -> [T]? { - + private func fetchRequest(entityType: T.Type, entityName: String, predicate: NSPredicate?, fetchLimit: Int?, in moc: NSManagedObjectContext) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: entityName) fetchRequest.predicate = predicate if let fetchLimit { fetchRequest.fetchLimit = fetchLimit } + return fetchRequest + } + + func fetch(entityType: T.Type, entityName: String, predicate: NSPredicate?, fetchLimit: Int?, in moc: NSManagedObjectContext) throws -> [T]? { + + let fetchRequest = fetchRequest(entityType: entityType, entityName: entityName, predicate: predicate, fetchLimit: fetchLimit, in: moc) return try moc.fetch(fetchRequest) } @@ -138,17 +138,49 @@ public final class WMFCoreDataStore { } } - func pruneTransactionHistory() throws { + public func performDatabaseHousekeeping() async throws { - guard let sevenDaysAgo = Calendar.current.date(byAdding: .day, + guard let sevenDaysAgoDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else { return } - let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgo) + let currentYear = Calendar.current.component(.year, from: Date()) + var dateComponents = DateComponents() + dateComponents.year = currentYear - 1 + dateComponents.day = 1 + dateComponents.month = 1 + + guard let oneYearAgoDate = Calendar.current.date(from: dateComponents) else { + return + } + let backgroundContext = try newBackgroundContext - try backgroundContext.execute(deleteHistoryRequest) + try await backgroundContext.perform { + + // Delete unused (for now) transaction history > 7 days ago + let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgoDate) + + try backgroundContext.execute(deleteHistoryRequest) + + // Delete WMFPageViews that were added > one year ago + let predicate = NSPredicate(format: "timestamp < %@", argumentArray: [oneYearAgoDate]) + let pageViewFetchRequest = NSFetchRequest(entityName: "WMFPageView") + pageViewFetchRequest.predicate = predicate + + let batchPageViewDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageViewFetchRequest) + batchPageViewDeleteRequest.resultType = .resultTypeObjectIDs + _ = try backgroundContext.execute(batchPageViewDeleteRequest) as! NSBatchDeleteResult + + // Delete WMFPages that were added > one year ago + let pageFetchRequest = NSFetchRequest(entityName: "WMFPage") + pageFetchRequest.predicate = predicate + + let batchPageDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageFetchRequest) + batchPageDeleteRequest.resultType = .resultTypeObjectIDs + _ = try backgroundContext.execute(batchPageDeleteRequest) as! NSBatchDeleteResult + } } } diff --git a/Wikipedia/Code/WMFAppViewController+Extensions.swift b/Wikipedia/Code/WMFAppViewController+Extensions.swift index 916df4b8e9c..4f7a900b510 100644 --- a/Wikipedia/Code/WMFAppViewController+Extensions.swift +++ b/Wikipedia/Code/WMFAppViewController+Extensions.swift @@ -600,6 +600,18 @@ extension WMFAppViewController { let languages = dataStore.languageLinkController.preferredLanguages.map { WMFLanguage(languageCode: $0.languageCode, languageVariantCode: $0.languageVariantCode) } WMFDataEnvironment.current.appData = WMFAppData(appLanguages: languages) } + + @objc func performWMFDataHousekeeping() { + let coreDataStore = WMFDataEnvironment.current.coreDataStore + Task { + do { + try await coreDataStore?.performDatabaseHousekeeping() + } catch { + DDLogError("Error pruning WMFData database: \(error)") + } + } + + } } // MARK: WMFComponents App Environment Helpers diff --git a/Wikipedia/Code/WMFAppViewController.m b/Wikipedia/Code/WMFAppViewController.m index f3155bcb7bb..969ed755e5b 100644 --- a/Wikipedia/Code/WMFAppViewController.m +++ b/Wikipedia/Code/WMFAppViewController.m @@ -647,6 +647,9 @@ - (void)performDatabaseHousekeepingWithCompletion:(void (^_Nonnull)(NSError *_Nu /// Housekeeping for the new talk page cache [SharedContainerCacheHousekeeping deleteStaleCachedItemsIn:SharedContainerCacheCommonNames.talkPageCache cleanupLevel:WMFCleanupLevelLow]; + + /// Housekeeping for WMFData + [self performWMFDataHousekeeping]; completion(housekeepingError); } From bbd4b2872798ac172ea322fc6649a624c1e6ea6e Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 17:19:31 -0500 Subject: [PATCH 11/30] Remove unnecessary changes --- .../Components/Watchlist/PageViewsView.swift | 170 ------------------ Wikipedia/Code/ArticleViewController.swift | 43 +++-- Wikipedia/Code/ExploreViewController.swift | 63 ++----- 3 files changed, 37 insertions(+), 239 deletions(-) delete mode 100644 WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift diff --git a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift b/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift deleted file mode 100644 index 87183b8d067..00000000000 --- a/WMFComponents/Sources/WMFComponents/Components/Watchlist/PageViewsView.swift +++ /dev/null @@ -1,170 +0,0 @@ -import SwiftUI -import CoreData -import WMFData -import SwiftData - -// Begin WMFData abstraction - -public protocol PageViewsViewDelegate: AnyObject { - func didTapPage(page: WMFPage) -} - - public struct PageViewsView: View { - - @State var pageViewCounts: [WMFPageViewCount] - weak var delegate: PageViewsViewDelegate? - - public init(pageViewCounts: [WMFPageViewCount] = [], delegate: PageViewsViewDelegate?) { - self.pageViewCounts = pageViewCounts - self.delegate = delegate - } - - public var body: some View { - List(pageViewCounts) { pageViewCount in - HStack { - Button(pageViewCount.page.title) { - delegate?.didTapPage(page: pageViewCount.page) - } - Text(String(pageViewCount.count)) - } -// .swipeActions { -// Button("Delete", systemImage: "trash", role: .destructive) { -// Task { -// do { -// try await WMFWikiWrappedDataController().deletePageView(pageView: pageViewCount.page) -// } catch { -// print(error) -// } -// } -// } -// } - } - .onAppear { - Task { - do { - let pageViewsCounts = try WMFWikiWrappedDataController().fetchPageViewCounts() - self.pageViewCounts = pageViewsCounts - } catch { - print(error) - } - } - } - } - } - -// End: WMFData abstraction - -// Begin: CoreData - -// public struct PageViewsViewCoreData: View { -// -// let moc: NSManagedObjectContext -// -// public init(moc: NSManagedObjectContext) { -// self.moc = moc -// } -// -// public var body: some View { -// PageViewsViewList() -// .environment(\.managedObjectContext, moc) -// } -// } -// -// struct PageViewsViewList: View { -// -// @FetchRequest(sortDescriptors: []) var pageViews: FetchedResults -// @Environment(\.managedObjectContext) var moc -// -// var body: some View { -// List(pageViews) { pageView in -// Text(pageView.page?.title ?? "") -// .swipeActions { -// Button("Delete", systemImage: "trash", role: .destructive) { -// moc.delete(pageView) -// try? moc.save() -// } -// } -// } -// } -// } - -// End: CoreData - -// Begin: SwiftData - -// @available(iOS 17, *) -// @Model -// public final class WMFPage { -// var namespace: Int -// var projectID: String -// public var title: String -// var pageViews: [WMFPageView] -// -// init(namespace: Int, projectID: String, title: String) { -// self.namespace = namespace -// self.projectID = projectID -// self.title = title -// self.pageViews = [] -// } -// } -// -// @available(iOS 17, *) -// @Model -// public final class WMFPageView { -// public var timestamp: Date -// public var page: WMFPage -// -// init(timestamp: Date, page: WMFPage) { -// self.timestamp = timestamp -// self.page = page -// } -// } -// -// @available(iOS 17, *) -// public struct PageViewsViewSwiftData: View { -// -// @State var pageViews: [WMFPageView] -// private let modelContainer: ModelContainer? -// -// public init(pageViews: [WMFPageView]) { -// self.pageViews = pageViews -// -// guard let appContainerURL = WMFDataEnvironment.current.appContainerURL else { -// self.modelContainer = nil -// return -// } -// -// let url = appContainerURL.appendingPathComponent("WMFData.sqlite") -// -// guard let modelContainer = try? ModelContainer(for: WMFPageView.self, configurations: ModelConfiguration(url: url)) else { -// self.modelContainer = nil -// return -// } -// -// self.modelContainer = modelContainer -// } -// -// -// public var body: some View { -// List(pageViews) { pageView in -// Text(pageView.page.title) -// .swipeActions { -// Button("Delete", systemImage: "trash", role: .destructive) { -// modelContainer?.mainContext.delete(pageView) -// } -// } -// } -// .onAppear { -// Task { @MainActor in -// -// let fetchDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\WMFPageView.timestamp, order: .forward)]) -// -// if let pageViews = try? modelContainer?.mainContext.fetch(fetchDescriptor) { -// self.pageViews = pageViews -// } -// } -// } -// } -// } - -// End: SwiftData diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index 032ca0cfe3e..9f674cab3dc 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -451,6 +451,7 @@ class ArticleViewController: ViewController, HintPresenting { } showAnnouncementIfNeeded() isFirstAppearance = false + persistPageViewForWikiwrapped() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -756,33 +757,17 @@ class ArticleViewController: ViewController, HintPresenting { var significantlyViewedTimer: Timer? func startSignificantlyViewedTimer() { - guard significantlyViewedTimer == nil else { + guard significantlyViewedTimer == nil, !article.wasSignificantlyViewed else { return } - significantlyViewedTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false, block: { [weak self] (timer) in + significantlyViewedTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false, block: { [weak self] (timer) in guard let self else { return } self.article.wasSignificantlyViewed = true - - if let title = self.articleURL.wmf_title, - let namespace = self.articleURL.namespace, - let siteURL = self.articleURL.wmf_site, - let project = WikimediaProject(siteURL: siteURL), - let wmfProject = project.wmfProject { - Task { - do { - let wikiwrappedDataController = try WMFWikiWrappedDataController() - try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) - } catch let error { - DDLogError("Error saving viewed page: \(error)") - } - } - } - self.stopSignificantlyViewedTimer() }) } @@ -1652,3 +1637,25 @@ extension ArticleViewController: WMFAltTextExperimentModalSheetLoggingDelegate { } } } + +// MARK: - Wikiwrapped +// TODO: Move to extension? + +private extension ArticleViewController { + func persistPageViewForWikiwrapped() { + if let title = self.articleURL.wmf_title, + let namespace = self.articleURL.namespace, + let siteURL = self.articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL), + let wmfProject = project.wmfProject { + Task { + do { + let wikiwrappedDataController = try WMFWikiWrappedDataController() + try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) + } catch let error { + DDLogError("Error saving viewed page: \(error)") + } + } + } + } +} diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index ca689ae4699..1ec5411a7cb 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -203,7 +203,17 @@ class ExploreViewController: ColumnarCollectionViewController, ExploreCardViewCo @objc func userDidTapProfile() { DonateFunnel.shared.logSettingsDidTapSettingsIcon() - settingsPresentationDelegate?.userDidTapProfile(from: self) + // settingsPresentationDelegate?.userDidTapProfile(from: self) + let profileView = WMFProfileView(isLoggedIn: true) + let hostingController = UIHostingController(rootView: profileView) + hostingController.modalPresentationStyle = .pageSheet + + if let sheetPresentationController = hostingController.sheetPresentationController { + sheetPresentationController.detents = [.large()] + sheetPresentationController.prefersGrabberVisible = true + } + + present(hostingController, animated: true, completion: nil) } open override func refresh() { @@ -1221,30 +1231,7 @@ extension ExploreViewController: ExploreCardCollectionViewCellDelegate { extension ExploreViewController { @objc func userDidTapNotificationsCenter() { - // notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) - - if #available(iOS 17, *) { - -// guard let moc = try? WMFDataEnvironment.current.coreDataStore?.viewContext else { -// return -// } -// let pageViews = PageViewsViewCoreData(moc: moc) - - let pageViews = PageViewsView(pageViewCounts: [], delegate: self) - // let pageViews = PageViewsViewSwiftData(pageViews: []) - - let hostingController = UIHostingController(rootView: pageViews) - hostingController.modalPresentationStyle = .pageSheet - - if let sheetPresentationController = hostingController.sheetPresentationController { - sheetPresentationController.detents = [.large()] - sheetPresentationController.prefersGrabberVisible = true - } - - present(hostingController, animated: true, completion: nil) - } else { - // Fallback on earlier versions - } + notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) } @objc func pushNotificationBannerDidDisplayInForeground(_ notification: Notification) { @@ -1961,29 +1948,3 @@ extension ExploreViewController: WMFAltTextPreviewDelegate { } } - -extension ExploreViewController: PageViewsViewDelegate { - func didTapPage(page: WMFData.WMFPage) { - - dismiss(animated: true) { - let projectElements = page.projectID.split(separator: "~") - - guard projectElements.count >= 2 else { - return - } - - let title = page.title - - switch projectElements[0] { - case "wikipedia": - let languageCode = projectElements[1] - let url = URL(string: "https://\(languageCode).wikipedia.org/wiki/\(title)")! - if let articleVC = ArticleViewController(articleURL: url, dataStore: self.dataStore, theme: self.theme) { - self.navigationController?.pushViewController(articleVC, animated: true) - } - default: - break - } - } - } -} From 0b8d26cb4a5e4e816e1933494a405cf03fd13fe4 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 17:57:15 -0500 Subject: [PATCH 12/30] Move persist method near history method --- ...Controller+ArticleWebMessageHandling.swift | 1 + Wikipedia/Code/ArticleViewController.swift | 39 ++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift index 89beedad38e..efb6ae7cb18 100644 --- a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift +++ b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift @@ -74,6 +74,7 @@ extension ArticleViewController: ArticleWebMessageHandling { assignScrollStateFromArticleFlagsIfNecessary() articleLoadWaitGroup?.leave() addToHistory() + persistPageViewForWikiwrapped() syncCachedResourcesIfNeeded() } diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index 9f674cab3dc..ec3b856d8ec 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -754,6 +754,23 @@ class ArticleViewController: ViewController, HintPresenting { try? article.addToReadHistory() } + func persistPageViewForWikiwrapped() { + if let title = self.articleURL.wmf_title, + let namespace = self.articleURL.namespace, + let siteURL = self.articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL), + let wmfProject = project.wmfProject { + Task { + do { + let wikiwrappedDataController = try WMFWikiWrappedDataController() + try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) + } catch let error { + DDLogError("Error saving viewed page: \(error)") + } + } + } + } + var significantlyViewedTimer: Timer? func startSignificantlyViewedTimer() { @@ -1637,25 +1654,3 @@ extension ArticleViewController: WMFAltTextExperimentModalSheetLoggingDelegate { } } } - -// MARK: - Wikiwrapped -// TODO: Move to extension? - -private extension ArticleViewController { - func persistPageViewForWikiwrapped() { - if let title = self.articleURL.wmf_title, - let namespace = self.articleURL.namespace, - let siteURL = self.articleURL.wmf_site, - let project = WikimediaProject(siteURL: siteURL), - let wmfProject = project.wmfProject { - Task { - do { - let wikiwrappedDataController = try WMFWikiWrappedDataController() - try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) - } catch let error { - DDLogError("Error saving viewed page: \(error)") - } - } - } - } -} From 02eb51f096287873bd774f916b80c7755cbbd68c Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 19:50:09 -0500 Subject: [PATCH 13/30] Progress on importing from history data --- .../MWKDataStore+WMFPageViewImport.swift | 68 ++++++++++++ .../WikiWrappedDataController.swift | 41 ++++++- Wikipedia.xcodeproj/project.pbxproj | 4 + Wikipedia/Code/MWKDataStore.m | 100 +++++++++++++++++- 4 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 WMF Framework/MWKDataStore+WMFPageViewImport.swift diff --git a/WMF Framework/MWKDataStore+WMFPageViewImport.swift b/WMF Framework/MWKDataStore+WMFPageViewImport.swift new file mode 100644 index 00000000000..963ff66330b --- /dev/null +++ b/WMF Framework/MWKDataStore+WMFPageViewImport.swift @@ -0,0 +1,68 @@ +import WMFData +import CocoaLumberjackSwift + +extension MWKDataStore { + @objc func importViewedArticlesIntoWMFData(dataStoreMOC: NSManagedObjectContext, completion: @escaping () -> Void) { + guard let dataController = try? WMFWikiWrappedDataController() else { + completion() + return + } + + let currentYear = Calendar.current.component(.year, from: Date()) + var dateComponents = DateComponents() + dateComponents.year = currentYear - 1 + dateComponents.day = 1 + dateComponents.month = 1 + + guard let oneYearAgoDate = Calendar.current.date(from: dateComponents) else { + return + } + + let articleRequest = WMFArticle.fetchRequest() + articleRequest.predicate = NSPredicate(format: "viewedDate >= %@", oneYearAgoDate as CVarArg) + do { + let articles = try dataStoreMOC.fetch(articleRequest) + + let importRequests: [WMFPageViewImportRequest] = articles.compactMap { article in + guard let key = article.key, + let viewedDate = article.viewedDate else { + return nil + } + + let url = URL(string: key) + guard let languageCode = url?.wmf_languageCode, + let title = url?.wmf_title else { + return nil + } + + let language = WMFLanguage(languageCode: languageCode, languageVariantCode: article.variant) + let project = WMFProject.wikipedia(language) + + return WMFPageViewImportRequest(title: title, project: project, viewedDate: viewedDate) + } + + Task { + do { + try await dataController.importPageViews(requests: importRequests) + await dataStoreMOC.perform { + completion() + } + } catch { + DDLogError("Error importing WMFPageViewImportRequests: \(error)") + await dataStoreMOC.perform { + completion() + } + } + } + + + } catch { + DDLogError("Error fetching viewed WMFArticles: \(error)") + dataStoreMOC.perform { + completion() + } + } + + } + +} diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index b832915c397..c209b9b2f6b 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -2,7 +2,7 @@ import Foundation import CoreData public final class WMFPage { - public let namespaceID: Int + public let namespaceID: Int public let projectID: String public let title: String let pageViews: [WMFPageView] @@ -40,6 +40,19 @@ public final class WMFPageViewCount: Identifiable { } } +public final class WMFPageViewImportRequest { + let title: String + let project: WMFProject + let viewedDate: Date + + public init(title: String, project: WMFProject, viewedDate: Date) { + self.title = title + self.project = project + self.viewedDate = viewedDate + } + +} + public final class WMFWikiWrappedDataController { private let coreDataStore: WMFCoreDataStore @@ -79,6 +92,32 @@ public final class WMFWikiWrappedDataController { } } + public func importPageViews(requests: [WMFPageViewImportRequest]) async throws { + + // TODO: Batch Import + + let backgroundContext = try coreDataStore.newBackgroundContext + try await backgroundContext.perform { + for request in requests { + + let coreDataTitle = request.title.normalizedForCoreData + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, coreDataTitle]) + + let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) + page?.title = coreDataTitle + page?.namespaceID = 0 + page?.projectID = request.project.coreDataIdentifier + page?.timestamp = request.viewedDate + + let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + viewedPage.page = page + viewedPage.timestamp = request.viewedDate + } + + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + } + } + public func fetchPageViewCounts() throws -> [WMFPageViewCount] { let viewContext = try coreDataStore.viewContext diff --git a/Wikipedia.xcodeproj/project.pbxproj b/Wikipedia.xcodeproj/project.pbxproj index ed66f4d43da..f6e014a783c 100644 --- a/Wikipedia.xcodeproj/project.pbxproj +++ b/Wikipedia.xcodeproj/project.pbxproj @@ -448,6 +448,7 @@ 672034E327A2531F007DC24F /* RemoteNotificationsReauthenticateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672034E227A2531F007DC24F /* RemoteNotificationsReauthenticateOperation.swift */; }; 672034E527A2600C007DC24F /* RemoteNotificationsProjectOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672034E427A2600C007DC24F /* RemoteNotificationsProjectOperation.swift */; }; 672285722540B56D0038E332 /* ReferenceBackLinksViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD7B85524B3B384005C2471 /* ReferenceBackLinksViewControllerDelegate.swift */; }; + 672344D02C98FC0D0021BB4F /* MWKDataStore+WMFPageViewImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672344CF2C98FBFA0021BB4F /* MWKDataStore+WMFPageViewImport.swift */; }; 6724288E23612AF000490629 /* DiffListContextCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 67CEF25E234FCA8100D5CA6C /* DiffListContextCell.xib */; }; 6724288F23612AF000490629 /* DiffListContextCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 67CEF25E234FCA8100D5CA6C /* DiffListContextCell.xib */; }; 6724289223612AF500490629 /* DiffListUneditedCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 67CEF2602350C29D00D5CA6C /* DiffListUneditedCell.xib */; }; @@ -3283,6 +3284,7 @@ 671F5E0A236B8CAF00111116 /* EmptyViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = EmptyViewController.xib; sourceTree = ""; }; 672034E227A2531F007DC24F /* RemoteNotificationsReauthenticateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationsReauthenticateOperation.swift; sourceTree = ""; }; 672034E427A2600C007DC24F /* RemoteNotificationsProjectOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationsProjectOperation.swift; sourceTree = ""; }; + 672344CF2C98FBFA0021BB4F /* MWKDataStore+WMFPageViewImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MWKDataStore+WMFPageViewImport.swift"; sourceTree = ""; }; 672428962362113400490629 /* DiffFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffFetcher.swift; sourceTree = ""; }; 67282FBC24855B7B00B73E20 /* ArticleContextMenuPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleContextMenuPresenting.swift; sourceTree = ""; }; 672C35EA22D8E7C9007B8D46 /* EmptyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyViewController.swift; sourceTree = ""; }; @@ -5753,6 +5755,7 @@ B0E807C11C0CF04A0065EBC0 /* MWKDataStore.h */, B0E807C21C0CF04A0065EBC0 /* MWKDataStore.m */, 535F16D525CE11A300875AAD /* MWKDataStore+LanguageVariantMigration.swift */, + 672344CF2C98FBFA0021BB4F /* MWKDataStore+WMFPageViewImport.swift */, B0E807B41C0CF0180065EBC0 /* MWKSavedPageList.h */, B0E807B51C0CF0180065EBC0 /* MWKSavedPageList.m */, D8C41DDA23FC09EE00353DCE /* NSManagedObjectContext+History.swift */, @@ -10624,6 +10627,7 @@ 837E619B2510E47400C67494 /* ArticleSummary.swift in Sources */, 0042806D25E6E395004945B3 /* FLAnimatedImageView.m in Sources */, 832A7A6023EAE03200D0A750 /* String+JavaScript.swift in Sources */, + 672344D02C98FC0D0021BB4F /* MWKDataStore+WMFPageViewImport.swift in Sources */, D837B5A81F06E5C600DCB9CD /* DateFormatter+WikipediaLanguage.swift in Sources */, D81EFDE41D775B6B0035F2EB /* SavedPageSpotlightManager.swift in Sources */, D81EFDE21D775B140035F2EB /* NSUserActivity+WMFExtensions.m in Sources */, diff --git a/Wikipedia/Code/MWKDataStore.m b/Wikipedia/Code/MWKDataStore.m index 7c152a6a5e2..244a002d56b 100644 --- a/Wikipedia/Code/MWKDataStore.m +++ b/Wikipedia/Code/MWKDataStore.m @@ -14,7 +14,7 @@ NSString *const WMFViewContextDidResetNotification = @"WMFViewContextDidResetNotification"; NSString *const WMFLibraryVersionKey = @"WMFLibraryVersion"; -static const NSInteger WMFCurrentLibraryVersion = 18; +static const NSInteger WMFCurrentLibraryVersion = 19; NSString *const WMFCoreDataSynchronizerInfoFileName = @"Wikipedia.info"; @@ -383,12 +383,17 @@ - (BOOL)migrateMainPageContentGroupInManagedObjectContext:(NSManagedObjectContex return [moc save:migrationError]; } -- (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inManagedObjectContext:(NSManagedObjectContext *)moc { +- (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inManagedObjectContext:(NSManagedObjectContext *)moc completion:(void (^)(void))completion { NSError *migrationError = nil; if (currentLibraryVersion < 5) { if (![self migrateToReadingListsInManagedObjectContext:moc error:&migrationError]) { DDLogError(@"Error during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -396,6 +401,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan if (currentLibraryVersion < 6) { if (![self migrateMainPageContentGroupInManagedObjectContext:moc error:&migrationError]) { DDLogError(@"Error during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -408,6 +418,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(8) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -417,6 +432,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(9) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -426,11 +446,21 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(10) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } if (![self moveImageControllerCacheFolderWithError:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -440,6 +470,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(11) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -449,6 +484,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(12) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -458,6 +498,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(13) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -471,6 +516,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(14) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -480,6 +530,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(15) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -489,6 +544,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(16) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -498,6 +558,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(17) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } @@ -507,9 +572,35 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(18) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + return; } } + + if (currentLibraryVersion < 19) { + [self importViewedArticlesIntoWMFDataWithDataStoreMOC:moc completion:^{ + + [moc wmf_setValue:@(19) forKey:WMFLibraryVersionKey]; + + if ([moc hasChanges] && ![moc save:nil]) { + DDLogError(@"Error saving during migration: %@", migrationError); + + if (completion) { + completion(); + } + + return; + } + + if (completion) { + completion(); + } + }]; + } // IMPORTANT: When adding a new library version and migration, update WMFCurrentLibraryVersion to the latest version number } @@ -563,8 +654,9 @@ - (void)performLibraryUpdates:(dispatch_block_t)completion { } [self performBackgroundCoreDataOperationOnATemporaryContext:^(NSManagedObjectContext *moc) { - [self performUpdatesFromLibraryVersion:currentUserLibraryVersion inManagedObjectContext:moc]; - combinedCompletion(); + [self performUpdatesFromLibraryVersion:currentUserLibraryVersion inManagedObjectContext:moc completion:^{ + combinedCompletion(); + }]; }]; } From 46825e66922d5e36d6d0927f1eabd84294a46bdb Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 20:00:12 -0500 Subject: [PATCH 14/30] Move WMFData core data setup call --- .../Code/WMFAppViewController+Extensions.swift | 17 ++++++++++------- Wikipedia/Code/WMFAppViewController.m | 2 ++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Wikipedia/Code/WMFAppViewController+Extensions.swift b/Wikipedia/Code/WMFAppViewController+Extensions.swift index 4f7a900b510..81676b21dd7 100644 --- a/Wikipedia/Code/WMFAppViewController+Extensions.swift +++ b/Wikipedia/Code/WMFAppViewController+Extensions.swift @@ -561,6 +561,16 @@ extension WMFAppViewController: CreateReadingListDelegate { // MARK: WMFData setup extension WMFAppViewController { + + @objc func setupWMFDataCoreDataStore() { + WMFDataEnvironment.current.appContainerURL = FileManager.default.wmf_containerURL() + do { + WMFDataEnvironment.current.coreDataStore = try WMFCoreDataStore() + } catch let error { + DDLogError("Error setting up WMFCoreDataStore: \(error)") + } + } + @objc func setupWMFDataEnvironment() { WMFDataEnvironment.current.mediaWikiService = MediaWikiFetcher(session: dataStore.session, configuration: dataStore.configuration) @@ -571,13 +581,6 @@ extension WMFAppViewController { WMFDataEnvironment.current.serviceEnvironment = .production } - WMFDataEnvironment.current.appContainerURL = FileManager.default.wmf_containerURL() - do { - WMFDataEnvironment.current.coreDataStore = try WMFCoreDataStore() - } catch let error { - DDLogError("Error setting up WMFCoreDataStore: \(error)") - } - WMFDataEnvironment.current.userAgentUtility = { return WikipediaAppUtils.versionedUserAgent() } diff --git a/Wikipedia/Code/WMFAppViewController.m b/Wikipedia/Code/WMFAppViewController.m index 969ed755e5b..64f8736b8a7 100644 --- a/Wikipedia/Code/WMFAppViewController.m +++ b/Wikipedia/Code/WMFAppViewController.m @@ -886,6 +886,8 @@ - (void)migrateIfNecessary { [self triggerMigratingAnimation]; } + [self setupWMFDataCoreDataStore]; + [dataStore performLibraryUpdates:^{ dispatch_async(dispatch_get_main_queue(), ^{ From ec7be945ec5cd960154854ff75965f8de3ef3b53 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 20:52:06 -0500 Subject: [PATCH 15/30] Remove double pageview call --- Wikipedia/Code/ArticleViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index ec3b856d8ec..4d78092fafd 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -451,7 +451,6 @@ class ArticleViewController: ViewController, HintPresenting { } showAnnouncementIfNeeded() isFirstAppearance = false - persistPageViewForWikiwrapped() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From b2193282b5fe36ae129c0203f92637c76311c3ce Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 21:05:27 -0500 Subject: [PATCH 16/30] Convert to batch insert progress --- .../WikiWrappedDataController.swift | 84 +++++++++++++++---- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index c209b9b2f6b..a0249baad87 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -94,28 +94,78 @@ public final class WMFWikiWrappedDataController { public func importPageViews(requests: [WMFPageViewImportRequest]) async throws { - // TODO: Batch Import - let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { - for request in requests { - - let coreDataTitle = request.title.normalizedForCoreData - let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, coreDataTitle]) + + let batchInsertPage = self.newBatchInsertPageRequest(requests: requests) + try backgroundContext.execute(batchInsertPage) + + let batchInsertPageView = self.newBatchInsertPageViewRequest(moc: backgroundContext, requests: requests) + try backgroundContext.execute(batchInsertPageView) + } + } + + private func newBatchInsertPageRequest(requests: [WMFPageViewImportRequest]) + -> NSBatchInsertRequest { + // 1 + var index = 0 + let total = requests.count + + // 2 + let batchInsert = NSBatchInsertRequest( + entity: CDPage.entity()) { (managedObject: NSManagedObject) -> Bool in + // 3 + guard index < total else { return true } + + if let page = managedObject as? CDPage { + // 4 + let request = requests[index] + let coreDataTitle = request.title.normalizedForCoreData + + page.title = coreDataTitle + page.namespaceID = 0 + page.projectID = request.project.coreDataIdentifier + page.timestamp = request.viewedDate + } + + // 5 + index += 1 + return false + } + return batchInsert + } + + private func newBatchInsertPageViewRequest(moc: NSManagedObjectContext, requests: [WMFPageViewImportRequest]) + -> NSBatchInsertRequest { + // 1 + var index = 0 + let total = requests.count + + // 2 + let batchInsert = NSBatchInsertRequest( + entity: CDPageView.entity()) { (managedObject: NSManagedObject) -> Bool in + // 3 + guard index < total else { return true } + + if let pageView = managedObject as? CDPageView { + // 4 + let request = requests[index] - let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) - page?.title = coreDataTitle - page?.namespaceID = 0 - page?.projectID = request.project.coreDataIdentifier - page?.timestamp = request.viewedDate + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, request.title.normalizedForCoreData]) + guard let page = try? self.coreDataStore.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, fetchLimit: 1, in: moc)?.first else { + index += 1 + return false + } - let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) - viewedPage.page = page - viewedPage.timestamp = request.viewedDate + pageView.page = page + pageView.timestamp = request.viewedDate } - - try self.coreDataStore.saveIfNeeded(moc: backgroundContext) - } + + // 5 + index += 1 + return false + } + return batchInsert } public func fetchPageViewCounts() throws -> [WMFPageViewCount] { From e5e5ed4ea26a413be037b6e1dc6ad2f21ef4af71 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 21:17:24 -0500 Subject: [PATCH 17/30] Revert "Convert to batch insert progress" This reverts commit b2193282b5fe36ae129c0203f92637c76311c3ce. --- .../WikiWrappedDataController.swift | 84 ++++--------------- 1 file changed, 17 insertions(+), 67 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index a0249baad87..c209b9b2f6b 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -94,78 +94,28 @@ public final class WMFWikiWrappedDataController { public func importPageViews(requests: [WMFPageViewImportRequest]) async throws { + // TODO: Batch Import + let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { - - let batchInsertPage = self.newBatchInsertPageRequest(requests: requests) - try backgroundContext.execute(batchInsertPage) - - let batchInsertPageView = self.newBatchInsertPageViewRequest(moc: backgroundContext, requests: requests) - try backgroundContext.execute(batchInsertPageView) - } - } - - private func newBatchInsertPageRequest(requests: [WMFPageViewImportRequest]) - -> NSBatchInsertRequest { - // 1 - var index = 0 - let total = requests.count - - // 2 - let batchInsert = NSBatchInsertRequest( - entity: CDPage.entity()) { (managedObject: NSManagedObject) -> Bool in - // 3 - guard index < total else { return true } - - if let page = managedObject as? CDPage { - // 4 - let request = requests[index] - let coreDataTitle = request.title.normalizedForCoreData - - page.title = coreDataTitle - page.namespaceID = 0 - page.projectID = request.project.coreDataIdentifier - page.timestamp = request.viewedDate - } - - // 5 - index += 1 - return false - } - return batchInsert - } - - private func newBatchInsertPageViewRequest(moc: NSManagedObjectContext, requests: [WMFPageViewImportRequest]) - -> NSBatchInsertRequest { - // 1 - var index = 0 - let total = requests.count - - // 2 - let batchInsert = NSBatchInsertRequest( - entity: CDPageView.entity()) { (managedObject: NSManagedObject) -> Bool in - // 3 - guard index < total else { return true } - - if let pageView = managedObject as? CDPageView { - // 4 - let request = requests[index] + for request in requests { - let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, request.title.normalizedForCoreData]) - guard let page = try? self.coreDataStore.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, fetchLimit: 1, in: moc)?.first else { - index += 1 - return false - } + let coreDataTitle = request.title.normalizedForCoreData + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, coreDataTitle]) - pageView.page = page - pageView.timestamp = request.viewedDate + let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) + page?.title = coreDataTitle + page?.namespaceID = 0 + page?.projectID = request.project.coreDataIdentifier + page?.timestamp = request.viewedDate + + let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + viewedPage.page = page + viewedPage.timestamp = request.viewedDate } - - // 5 - index += 1 - return false - } - return batchInsert + + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + } } public func fetchPageViewCounts() throws -> [WMFPageViewCount] { From 73901ac0aed9c05ec33ab31a5a3e29dd7ba92ce8 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 21:32:38 -0500 Subject: [PATCH 18/30] Delete all page views --- .../WikiWrappedDataController.swift | 20 +++++++++++++++++-- Wikipedia/Code/HistoryViewController.swift | 12 +++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index c209b9b2f6b..001e0cc89c5 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -92,10 +92,26 @@ public final class WMFWikiWrappedDataController { } } + public func deletePageViews() async throws { + let backgroundContext = try coreDataStore.newBackgroundContext + try await backgroundContext.perform { [weak self] in + + guard let self else { return } + + guard let pageViews = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: backgroundContext) else { + return + } + + for pageView in pageViews { + backgroundContext.delete(pageView) + } + + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + } + } + public func importPageViews(requests: [WMFPageViewImportRequest]) async throws { - // TODO: Batch Import - let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { for request in requests { diff --git a/Wikipedia/Code/HistoryViewController.swift b/Wikipedia/Code/HistoryViewController.swift index 887060b2e1e..a371b51ea9d 100644 --- a/Wikipedia/Code/HistoryViewController.swift +++ b/Wikipedia/Code/HistoryViewController.swift @@ -1,5 +1,7 @@ import UIKit import WMF +import WMFData +import CocoaLumberjackSwift @objc(WMFHistoryViewController) class HistoryViewController: ArticleFetchedResultsViewController { @@ -52,6 +54,16 @@ class HistoryViewController: ArticleFetchedResultsViewController { } catch let error { showError(error) } + + Task { + do { + let dataController = try WMFWikiWrappedDataController() + try await dataController.deletePageViews() + } catch { + DDLogError("Failure deleting WMFData WMFPageViews: \(error)") + } + + } } override var headerStyle: ColumnarCollectionViewController.HeaderStyle { From b6ef0f4ddedfeb863c2a9cb9d896539a42a3cf15 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 16 Sep 2024 22:13:15 -0500 Subject: [PATCH 19/30] Allow deleting individual page views, reduce import amount --- .../MWKDataStore+WMFPageViewImport.swift | 2 +- .../WikiWrappedDataController.swift | 30 +++++++++++++++++- Wikipedia/Code/HistoryViewController.swift | 31 ++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/WMF Framework/MWKDataStore+WMFPageViewImport.swift b/WMF Framework/MWKDataStore+WMFPageViewImport.swift index 963ff66330b..5b46dc3b8da 100644 --- a/WMF Framework/MWKDataStore+WMFPageViewImport.swift +++ b/WMF Framework/MWKDataStore+WMFPageViewImport.swift @@ -10,7 +10,7 @@ extension MWKDataStore { let currentYear = Calendar.current.component(.year, from: Date()) var dateComponents = DateComponents() - dateComponents.year = currentYear - 1 + dateComponents.year = currentYear dateComponents.day = 1 dateComponents.month = 1 diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 001e0cc89c5..09565d383e3 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -92,7 +92,35 @@ public final class WMFWikiWrappedDataController { } } - public func deletePageViews() async throws { + public func deletePageView(title: String, namespaceID: Int16, project: WMFProject) async throws { + + let coreDataTitle = title.normalizedForCoreData + + let backgroundContext = try coreDataStore.newBackgroundContext + try await backgroundContext.perform { [weak self] in + + guard let self else { return } + + let pagePredicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [project.coreDataIdentifier, namespaceID, coreDataTitle]) + guard let page = try self.coreDataStore.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: pagePredicate, fetchLimit: 1, in: backgroundContext)?.first else { + return + } + + let pageViewsPredicate = NSPredicate(format: "page == %@", argumentArray: [page]) + + guard let pageViews = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: pageViewsPredicate, fetchLimit: nil, in: backgroundContext) else { + return + } + + for pageView in pageViews { + backgroundContext.delete(pageView) + } + + try coreDataStore.saveIfNeeded(moc: backgroundContext) + } + } + + public func deleteAllPageViews() async throws { let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { [weak self] in diff --git a/Wikipedia/Code/HistoryViewController.swift b/Wikipedia/Code/HistoryViewController.swift index a371b51ea9d..6244c3a62d5 100644 --- a/Wikipedia/Code/HistoryViewController.swift +++ b/Wikipedia/Code/HistoryViewController.swift @@ -58,7 +58,36 @@ class HistoryViewController: ArticleFetchedResultsViewController { Task { do { let dataController = try WMFWikiWrappedDataController() - try await dataController.deletePageViews() + try await dataController.deleteAllPageViews() + } catch { + DDLogError("Failure deleting WMFData WMFPageViews: \(error)") + } + + } + } + + override func delete(at indexPath: IndexPath) { + + guard let article = article(at: indexPath) else { + return + } + + super.delete(at: indexPath) + + // Also delete from WMFData WMFPageViews + guard let title = article.url?.wmf_title, + let languageCode = article.url?.wmf_languageCode else { + return + } + + let variant = article.variant + + let project = WMFProject.wikipedia(WMFLanguage(languageCode: languageCode, languageVariantCode: variant)) + + Task { + do { + let dataController = try WMFWikiWrappedDataController() + try await dataController.deletePageView(title: title, namespaceID: 0, project: project) } catch { DDLogError("Failure deleting WMFData WMFPageViews: \(error)") } From be19c82a310a29887a4875c6e5dc7bedbd472f23 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 07:21:18 -0500 Subject: [PATCH 20/30] Add CoreDataStore tests --- .../WMFData.xcdatamodel/contents | 4 +- .../WMFData/Store/WMFCoreDataStore.swift | 2 +- .../WMFDataTests/WMFCoreDataStoreTests.swift | 191 ++++++++++++++++++ 3 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift diff --git a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents index ba69e1259d8..cab9eeaf13e 100644 --- a/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents +++ b/WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents @@ -5,7 +5,7 @@ - + @@ -18,6 +18,6 @@ - + \ No newline at end of file diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index ebfbcd6bbce..cdffe5b74e7 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -75,7 +75,7 @@ public final class WMFCoreDataStore { } } - func fetchOrCreate(entityType: T.Type, entityName: String, predicate: NSPredicate, in moc: NSManagedObjectContext) throws -> T? { + func fetchOrCreate(entityType: T.Type, entityName: String, predicate: NSPredicate?, in moc: NSManagedObjectContext) throws -> T? { guard let existing: [T] = try fetch(entityType: entityType, entityName: entityName, predicate: predicate, fetchLimit: 1, in: moc), !existing.isEmpty else { diff --git a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift new file mode 100644 index 00000000000..513a3b98819 --- /dev/null +++ b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift @@ -0,0 +1,191 @@ + +import XCTest +@testable import WMFData +import CoreData + +final class WMFCoreDataStoreTests: XCTestCase { + + enum WMFCoreDataStoreTestsError: Error { + case empty + } + + lazy var store: WMFCoreDataStore = { + let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + return try! WMFCoreDataStore(appContainerURL: temporaryDirectory) + }() + + override func setUp() async throws { + let _ = self.store + // Wait for store to load asyncronously + try await Task.sleep(nanoseconds: 1_000_000_000) + } + + func testCreateAndFetch() throws { + + // First save new record + let backgroundContext = try store.newBackgroundContext + let page = try store.create(entityType: CDPage.self, entityName: "WMFPage", in: backgroundContext) + page.title = "Cat" + page.namespaceID = 0 + page.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + page.timestamp = Date() + + try store.saveIfNeeded(moc: backgroundContext) + + // Then pull from store, confirm values match + guard let pulledPage = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: nil, fetchLimit: 1, in: backgroundContext)?.first else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(pulledPage.title, "Cat") + XCTAssertEqual(pulledPage.namespaceID, 0) + XCTAssertEqual(pulledPage.projectID, "wikipedia~en") + XCTAssertNotNil(pulledPage.timestamp) + } + + func testFetchOrCreate() throws { + + // First confirm no items in store + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: ["wikipedia~en", 0, "Dog"]) + + let backgroundContext = try store.newBackgroundContext + guard let initialPages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(initialPages.count, 0) + + // Then fetch or create + guard let savedPage = try store.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + savedPage.title = "Dog" + savedPage.namespaceID = 0 + savedPage.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + savedPage.timestamp = Date() + + try store.saveIfNeeded(moc: backgroundContext) + + // Then pull from store again, confirm only one page saved + guard let nextPages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(nextPages.count, 1) + + // Try saving again, confirm we don't add a duplicate + guard let anotherSavedPage = try store.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + anotherSavedPage.title = "Dog" + anotherSavedPage.namespaceID = 0 + anotherSavedPage.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + anotherSavedPage.timestamp = Date() + + try store.saveIfNeeded(moc: backgroundContext) + + // Then pull from store once more, confirm still only one page saved + guard let finalPages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(finalPages.count, 1) + } + + func testFetchGrouped() throws { + + // First save new records + let backgroundContext = try store.newBackgroundContext + let page = try store.create(entityType: CDPage.self, entityName: "WMFPage", in: backgroundContext) + page.title = "Cat" + page.namespaceID = 0 + page.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + page.timestamp = Date() + + let pageView1 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + pageView1.timestamp = Date.init(timeIntervalSinceNow: -(60*60)) + pageView1.page = page + + let pageView2 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + pageView2.timestamp = Date() + pageView2.page = page + + try store.saveIfNeeded(moc: backgroundContext) + + // Then fetch grouped and compare counts + let pageViews = try store.fetchGrouped(entityName: "WMFPageView", predicate: nil, propertyToCount: "page", propertiesToGroupBy: ["page"], propertiesToFetch: ["page"], in: backgroundContext) + XCTAssertEqual(pageViews.count, 1) + let pageViewsDict = pageViews[0] + XCTAssertEqual(pageViewsDict["page"] as? NSManagedObjectID, page.objectID) + XCTAssertEqual(pageViewsDict["count"] as? Int, 2) + } + + func testDatabaseHousekeeping() async throws { + + let overTwoYearsAgoInSeconds = TimeInterval(60 * 60 * 24 * 800) + + // First save new records + let backgroundContext = try store.newBackgroundContext + let catPage = try store.create(entityType: CDPage.self, entityName: "WMFPage", in: backgroundContext) + catPage.title = "Cat" + catPage.namespaceID = 0 + catPage.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + catPage.timestamp = Date(timeIntervalSinceNow: -(60*60)) + + let catPageView1 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + catPageView1.timestamp = Date(timeIntervalSinceNow: -(60*60)) + catPageView1.page = catPage + + let catPageView2 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + catPageView2.timestamp = Date() + catPageView2.page = catPage + + let dogPage = try store.create(entityType: CDPage.self, entityName: "WMFPage", in: backgroundContext) + dogPage.title = "Dog" + dogPage.namespaceID = 0 + dogPage.projectID = WMFProject.wikipedia(WMFLanguage(languageCode: "en", languageVariantCode: nil)).coreDataIdentifier + dogPage.timestamp = Date(timeIntervalSinceNow: -(overTwoYearsAgoInSeconds + 20)) + + let dogPageView1 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + dogPageView1.timestamp = Date(timeIntervalSinceNow: -(overTwoYearsAgoInSeconds + 20)) + dogPageView1.page = dogPage + + let dogPageView2 = try store.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + dogPageView2.timestamp = Date(timeIntervalSinceNow: -(overTwoYearsAgoInSeconds)) + dogPageView2.page = dogPage + + try store.saveIfNeeded(moc: backgroundContext) + + // Confirm counts + guard let pages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: nil, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(pages.count, 2) + + guard let pageViews = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(pageViews.count, 4) + + // Clean up via database housekeeper + try await store.performDatabaseHousekeeping() + + backgroundContext.reset() + + guard let newPages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: nil, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(newPages.count, 1) + + guard let newPageViews = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: backgroundContext) else { + throw WMFCoreDataStoreTestsError.empty + } + + XCTAssertEqual(newPageViews.count, 2) + } +} From e98598c9976efbe7726980d6c53df800b91da108 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 08:25:49 -0500 Subject: [PATCH 21/30] Use better refresh method --- WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift index 513a3b98819..badd1830b17 100644 --- a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift +++ b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift @@ -174,7 +174,7 @@ final class WMFCoreDataStoreTests: XCTestCase { // Clean up via database housekeeper try await store.performDatabaseHousekeeping() - backgroundContext.reset() + backgroundContext.refreshAllObjects() guard let newPages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: nil, fetchLimit: nil, in: backgroundContext) else { throw WMFCoreDataStoreTestsError.empty From 7c97e48110a295fccf7eecc5ddd60d36f8af6d92 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 09:10:10 -0500 Subject: [PATCH 22/30] Try to fix batch import, no luck --- .../WikiWrappedDataController.swift | 90 +++++++++++++++---- .../WMFDataTests/WMFCoreDataStoreTests.swift | 3 +- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 09565d383e3..e9b207e3017 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -44,6 +44,7 @@ public final class WMFPageViewImportRequest { let title: String let project: WMFProject let viewedDate: Date + var pageObjectID: NSManagedObjectID? public init(title: String, project: WMFProject, viewedDate: Date) { self.title = title @@ -142,24 +143,81 @@ public final class WMFWikiWrappedDataController { let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { - for request in requests { - - let coreDataTitle = request.title.normalizedForCoreData - let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, coreDataTitle]) - - let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) - page?.title = coreDataTitle - page?.namespaceID = 0 - page?.projectID = request.project.coreDataIdentifier - page?.timestamp = request.viewedDate - - let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) - viewedPage.page = page - viewedPage.timestamp = request.viewedDate - } - try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + let batchInsertPage = self.newBatchInsertPageRequest(requests: requests) + try backgroundContext.execute(batchInsertPage) + } + + backgroundContext.refreshAllObjects() + + try await backgroundContext.perform { + let batchInsertPageView = self.newBatchInsertPageViewRequest(moc: backgroundContext, requests: requests) + try backgroundContext.execute(batchInsertPageView) + } + } + + private func newBatchInsertPageRequest(requests: [WMFPageViewImportRequest]) + -> NSBatchInsertRequest { + // 1 + var index = 0 + let total = requests.count + + // 2 + let batchInsert = NSBatchInsertRequest( + entity: CDPage.entity()) { (managedObject: NSManagedObject) -> Bool in + // 3 + guard index < total else { return true } + + if let page = managedObject as? CDPage { + // 4 + let request = requests[index] + let coreDataTitle = request.title.normalizedForCoreData + + page.title = coreDataTitle + page.namespaceID = 0 + page.projectID = request.project.coreDataIdentifier + page.timestamp = request.viewedDate + page.pageViews = [] + request.pageObjectID = page.objectID } + + // 5 + index += 1 + return false + } + return batchInsert + } + + private func newBatchInsertPageViewRequest(moc: NSManagedObjectContext, requests: [WMFPageViewImportRequest]) + -> NSBatchInsertRequest { + // 1 + var index = 0 + let total = requests.count + + // 2 + let batchInsert = NSBatchInsertRequest( + entity: CDPageView.entity()) { (managedObject: NSManagedObject) -> Bool in + // 3 + guard index < total else { return true } + + let request = requests[index] + + guard let pageView = managedObject as? CDPageView, + let pageObjectID = request.pageObjectID, + let page = managedObject.managedObjectContext?.object(with: pageObjectID) as? CDPage else { + index += 1 + return false + } + // 4 + + pageView.page = page + pageView.timestamp = request.viewedDate + + // 5 + index += 1 + return false + } + return batchInsert } public func fetchPageViewCounts() throws -> [WMFPageViewCount] { diff --git a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift index badd1830b17..e58a75267a7 100644 --- a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift +++ b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift @@ -1,4 +1,3 @@ - import XCTest @testable import WMFData import CoreData @@ -15,7 +14,7 @@ final class WMFCoreDataStoreTests: XCTestCase { }() override func setUp() async throws { - let _ = self.store + _ = self.store // Wait for store to load asyncronously try await Task.sleep(nanoseconds: 1_000_000_000) } From a360e6995a7eba184100a17f1fceb190792e15ec Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 09:10:16 -0500 Subject: [PATCH 23/30] Revert "Try to fix batch import, no luck" This reverts commit 7c97e48110a295fccf7eecc5ddd60d36f8af6d92. --- .../WikiWrappedDataController.swift | 90 ++++--------------- .../WMFDataTests/WMFCoreDataStoreTests.swift | 3 +- 2 files changed, 18 insertions(+), 75 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index e9b207e3017..09565d383e3 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -44,7 +44,6 @@ public final class WMFPageViewImportRequest { let title: String let project: WMFProject let viewedDate: Date - var pageObjectID: NSManagedObjectID? public init(title: String, project: WMFProject, viewedDate: Date) { self.title = title @@ -143,81 +142,24 @@ public final class WMFWikiWrappedDataController { let backgroundContext = try coreDataStore.newBackgroundContext try await backgroundContext.perform { + for request in requests { + + let coreDataTitle = request.title.normalizedForCoreData + let predicate = NSPredicate(format: "projectID == %@ && namespaceID == %@ && title == %@", argumentArray: [request.project.coreDataIdentifier, 0, coreDataTitle]) + + let page = try self.coreDataStore.fetchOrCreate(entityType: CDPage.self, entityName: "WMFPage", predicate: predicate, in: backgroundContext) + page?.title = coreDataTitle + page?.namespaceID = 0 + page?.projectID = request.project.coreDataIdentifier + page?.timestamp = request.viewedDate + + let viewedPage = try self.coreDataStore.create(entityType: CDPageView.self, entityName: "WMFPageView", in: backgroundContext) + viewedPage.page = page + viewedPage.timestamp = request.viewedDate + } - let batchInsertPage = self.newBatchInsertPageRequest(requests: requests) - try backgroundContext.execute(batchInsertPage) - } - - backgroundContext.refreshAllObjects() - - try await backgroundContext.perform { - let batchInsertPageView = self.newBatchInsertPageViewRequest(moc: backgroundContext, requests: requests) - try backgroundContext.execute(batchInsertPageView) - } - } - - private func newBatchInsertPageRequest(requests: [WMFPageViewImportRequest]) - -> NSBatchInsertRequest { - // 1 - var index = 0 - let total = requests.count - - // 2 - let batchInsert = NSBatchInsertRequest( - entity: CDPage.entity()) { (managedObject: NSManagedObject) -> Bool in - // 3 - guard index < total else { return true } - - if let page = managedObject as? CDPage { - // 4 - let request = requests[index] - let coreDataTitle = request.title.normalizedForCoreData - - page.title = coreDataTitle - page.namespaceID = 0 - page.projectID = request.project.coreDataIdentifier - page.timestamp = request.viewedDate - page.pageViews = [] - request.pageObjectID = page.objectID + try self.coreDataStore.saveIfNeeded(moc: backgroundContext) } - - // 5 - index += 1 - return false - } - return batchInsert - } - - private func newBatchInsertPageViewRequest(moc: NSManagedObjectContext, requests: [WMFPageViewImportRequest]) - -> NSBatchInsertRequest { - // 1 - var index = 0 - let total = requests.count - - // 2 - let batchInsert = NSBatchInsertRequest( - entity: CDPageView.entity()) { (managedObject: NSManagedObject) -> Bool in - // 3 - guard index < total else { return true } - - let request = requests[index] - - guard let pageView = managedObject as? CDPageView, - let pageObjectID = request.pageObjectID, - let page = managedObject.managedObjectContext?.object(with: pageObjectID) as? CDPage else { - index += 1 - return false - } - // 4 - - pageView.page = page - pageView.timestamp = request.viewedDate - - // 5 - index += 1 - return false - } - return batchInsert } public func fetchPageViewCounts() throws -> [WMFPageViewCount] { diff --git a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift index e58a75267a7..badd1830b17 100644 --- a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift +++ b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift @@ -1,3 +1,4 @@ + import XCTest @testable import WMFData import CoreData @@ -14,7 +15,7 @@ final class WMFCoreDataStoreTests: XCTestCase { }() override func setUp() async throws { - _ = self.store + let _ = self.store // Wait for store to load asyncronously try await Task.sleep(nanoseconds: 1_000_000_000) } From 8af3265fff779d53a0d01b971e938d2731dc675b Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 09:13:45 -0500 Subject: [PATCH 24/30] Rework import to be fire-and-forget --- .../MWKDataStore+WMFPageViewImport.swift | 12 +-- Wikipedia/Code/MWKDataStore.m | 101 ++---------------- 2 files changed, 10 insertions(+), 103 deletions(-) diff --git a/WMF Framework/MWKDataStore+WMFPageViewImport.swift b/WMF Framework/MWKDataStore+WMFPageViewImport.swift index 5b46dc3b8da..b61fdc52b48 100644 --- a/WMF Framework/MWKDataStore+WMFPageViewImport.swift +++ b/WMF Framework/MWKDataStore+WMFPageViewImport.swift @@ -2,9 +2,8 @@ import WMFData import CocoaLumberjackSwift extension MWKDataStore { - @objc func importViewedArticlesIntoWMFData(dataStoreMOC: NSManagedObjectContext, completion: @escaping () -> Void) { + @objc func importViewedArticlesIntoWMFData(dataStoreMOC: NSManagedObjectContext) { guard let dataController = try? WMFWikiWrappedDataController() else { - completion() return } @@ -44,23 +43,14 @@ extension MWKDataStore { Task { do { try await dataController.importPageViews(requests: importRequests) - await dataStoreMOC.perform { - completion() - } } catch { DDLogError("Error importing WMFPageViewImportRequests: \(error)") - await dataStoreMOC.perform { - completion() - } } } } catch { DDLogError("Error fetching viewed WMFArticles: \(error)") - dataStoreMOC.perform { - completion() - } } } diff --git a/Wikipedia/Code/MWKDataStore.m b/Wikipedia/Code/MWKDataStore.m index 244a002d56b..1d431540476 100644 --- a/Wikipedia/Code/MWKDataStore.m +++ b/Wikipedia/Code/MWKDataStore.m @@ -383,17 +383,12 @@ - (BOOL)migrateMainPageContentGroupInManagedObjectContext:(NSManagedObjectContex return [moc save:migrationError]; } -- (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inManagedObjectContext:(NSManagedObjectContext *)moc completion:(void (^)(void))completion { +- (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inManagedObjectContext:(NSManagedObjectContext *)moc { NSError *migrationError = nil; if (currentLibraryVersion < 5) { if (![self migrateToReadingListsInManagedObjectContext:moc error:&migrationError]) { DDLogError(@"Error during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -401,11 +396,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan if (currentLibraryVersion < 6) { if (![self migrateMainPageContentGroupInManagedObjectContext:moc error:&migrationError]) { DDLogError(@"Error during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -418,11 +408,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(8) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -432,11 +417,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(9) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -446,21 +426,11 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(10) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } if (![self moveImageControllerCacheFolderWithError:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -470,11 +440,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(11) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -484,11 +449,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(12) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -498,11 +458,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(13) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -516,11 +471,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(14) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -530,11 +480,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(15) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -544,11 +489,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(16) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -558,11 +498,6 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(17) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } @@ -572,34 +507,17 @@ - (void)performUpdatesFromLibraryVersion:(NSUInteger)currentLibraryVersion inMan [moc wmf_setValue:@(18) forKey:WMFLibraryVersionKey]; if ([moc hasChanges] && ![moc save:&migrationError]) { DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - return; } } if (currentLibraryVersion < 19) { - [self importViewedArticlesIntoWMFDataWithDataStoreMOC:moc completion:^{ - - [moc wmf_setValue:@(19) forKey:WMFLibraryVersionKey]; - - if ([moc hasChanges] && ![moc save:nil]) { - DDLogError(@"Error saving during migration: %@", migrationError); - - if (completion) { - completion(); - } - - return; - } - - if (completion) { - completion(); - } - }]; + [self importViewedArticlesIntoWMFDataWithDataStoreMOC:moc]; + [moc wmf_setValue:@(19) forKey:WMFLibraryVersionKey]; + if ([moc hasChanges] && ![moc save:nil]) { + DDLogError(@"Error saving during migration: %@", migrationError); + return; + } } // IMPORTANT: When adding a new library version and migration, update WMFCurrentLibraryVersion to the latest version number @@ -654,9 +572,8 @@ - (void)performLibraryUpdates:(dispatch_block_t)completion { } [self performBackgroundCoreDataOperationOnATemporaryContext:^(NSManagedObjectContext *moc) { - [self performUpdatesFromLibraryVersion:currentUserLibraryVersion inManagedObjectContext:moc completion:^{ - combinedCompletion(); - }]; + [self performUpdatesFromLibraryVersion:currentUserLibraryVersion inManagedObjectContext:moc]; + combinedCompletion(); }]; } From 6957b3fbe1ff459232eb997316ea1521883af721 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 09:19:20 -0500 Subject: [PATCH 25/30] Avoid ! --- WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift | 4 ++-- WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index cdffe5b74e7..18f68e271e2 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -171,7 +171,7 @@ public final class WMFCoreDataStore { let batchPageViewDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageViewFetchRequest) batchPageViewDeleteRequest.resultType = .resultTypeObjectIDs - _ = try backgroundContext.execute(batchPageViewDeleteRequest) as! NSBatchDeleteResult + _ = try backgroundContext.execute(batchPageViewDeleteRequest) as? NSBatchDeleteResult // Delete WMFPages that were added > one year ago let pageFetchRequest = NSFetchRequest(entityName: "WMFPage") @@ -179,7 +179,7 @@ public final class WMFCoreDataStore { let batchPageDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageFetchRequest) batchPageDeleteRequest.resultType = .resultTypeObjectIDs - _ = try backgroundContext.execute(batchPageDeleteRequest) as! NSBatchDeleteResult + _ = try backgroundContext.execute(batchPageDeleteRequest) as? NSBatchDeleteResult } } } diff --git a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift index badd1830b17..e58a75267a7 100644 --- a/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift +++ b/WMFData/Tests/WMFDataTests/WMFCoreDataStoreTests.swift @@ -1,4 +1,3 @@ - import XCTest @testable import WMFData import CoreData @@ -15,7 +14,7 @@ final class WMFCoreDataStoreTests: XCTestCase { }() override func setUp() async throws { - let _ = self.store + _ = self.store // Wait for store to load asyncronously try await Task.sleep(nanoseconds: 1_000_000_000) } From cc2d4dbefa5a96de540eaf985c9acdabde835131 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 17 Sep 2024 09:21:47 -0500 Subject: [PATCH 26/30] Prefer batch delete when clearing history --- .../WikiWrappedDataController.swift | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift index 09565d383e3..1446264bd01 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift @@ -122,19 +122,14 @@ public final class WMFWikiWrappedDataController { public func deleteAllPageViews() async throws { let backgroundContext = try coreDataStore.newBackgroundContext - try await backgroundContext.perform { [weak self] in - - guard let self else { return } - - guard let pageViews = try self.coreDataStore.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: backgroundContext) else { - return - } - - for pageView in pageViews { - backgroundContext.delete(pageView) - } + try await backgroundContext.perform { + let pageViewFetchRequest = NSFetchRequest(entityName: "WMFPageView") - try self.coreDataStore.saveIfNeeded(moc: backgroundContext) + let batchPageViewDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageViewFetchRequest) + batchPageViewDeleteRequest.resultType = .resultTypeObjectIDs + _ = try backgroundContext.execute(batchPageViewDeleteRequest) as? NSBatchDeleteResult + + backgroundContext.refreshAllObjects() } } From 8940d48ff9797bb4ea35c3fc0699c7c075c9f584 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 30 Sep 2024 16:55:31 -0500 Subject: [PATCH 27/30] Rename to WMFPageViewsDataController, year in review --- WMF Framework/MWKDataStore+WMFPageViewImport.swift | 2 +- .../WMFPageViewsDataController.swift} | 2 +- .../ArticleViewController+ArticleWebMessageHandling.swift | 2 +- Wikipedia/Code/ArticleViewController.swift | 6 +++--- Wikipedia/Code/HistoryViewController.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename WMFData/Sources/WMFData/Data Controllers/{WikiWrapped/WikiWrappedDataController.swift => Shared/WMFPageViewsDataController.swift} (99%) diff --git a/WMF Framework/MWKDataStore+WMFPageViewImport.swift b/WMF Framework/MWKDataStore+WMFPageViewImport.swift index b61fdc52b48..24e50ce89ed 100644 --- a/WMF Framework/MWKDataStore+WMFPageViewImport.swift +++ b/WMF Framework/MWKDataStore+WMFPageViewImport.swift @@ -3,7 +3,7 @@ import CocoaLumberjackSwift extension MWKDataStore { @objc func importViewedArticlesIntoWMFData(dataStoreMOC: NSManagedObjectContext) { - guard let dataController = try? WMFWikiWrappedDataController() else { + guard let dataController = try? WMFPageViewsDataController() else { return } diff --git a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift b/WMFData/Sources/WMFData/Data Controllers/Shared/WMFPageViewsDataController.swift similarity index 99% rename from WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift rename to WMFData/Sources/WMFData/Data Controllers/Shared/WMFPageViewsDataController.swift index 1446264bd01..e131eb0b5a1 100644 --- a/WMFData/Sources/WMFData/Data Controllers/WikiWrapped/WikiWrappedDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/Shared/WMFPageViewsDataController.swift @@ -53,7 +53,7 @@ public final class WMFPageViewImportRequest { } -public final class WMFWikiWrappedDataController { +public final class WMFPageViewsDataController { private let coreDataStore: WMFCoreDataStore diff --git a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift index efb6ae7cb18..a2e93e52933 100644 --- a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift +++ b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift @@ -74,7 +74,7 @@ extension ArticleViewController: ArticleWebMessageHandling { assignScrollStateFromArticleFlagsIfNecessary() articleLoadWaitGroup?.leave() addToHistory() - persistPageViewForWikiwrapped() + persistPageViewsForYearInReview() syncCachedResourcesIfNeeded() } diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index 2a2747151b2..e1ec3484df0 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -761,7 +761,7 @@ class ArticleViewController: ViewController, HintPresenting { try? article.addToReadHistory() } - func persistPageViewForWikiwrapped() { + func persistPageViewsForYearInReview() { if let title = self.articleURL.wmf_title, let namespace = self.articleURL.namespace, let siteURL = self.articleURL.wmf_site, @@ -769,8 +769,8 @@ class ArticleViewController: ViewController, HintPresenting { let wmfProject = project.wmfProject { Task { do { - let wikiwrappedDataController = try WMFWikiWrappedDataController() - try await wikiwrappedDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) + let pageViewsDataController = try WMFPageViewsDataController() + try await pageViewsDataController.addPageView(title: title, namespaceID: Int16(namespace.rawValue), project: wmfProject) } catch let error { DDLogError("Error saving viewed page: \(error)") } diff --git a/Wikipedia/Code/HistoryViewController.swift b/Wikipedia/Code/HistoryViewController.swift index 6244c3a62d5..2c1a0a177a6 100644 --- a/Wikipedia/Code/HistoryViewController.swift +++ b/Wikipedia/Code/HistoryViewController.swift @@ -57,7 +57,7 @@ class HistoryViewController: ArticleFetchedResultsViewController { Task { do { - let dataController = try WMFWikiWrappedDataController() + let dataController = try WMFPageViewsDataController() try await dataController.deleteAllPageViews() } catch { DDLogError("Failure deleting WMFData WMFPageViews: \(error)") @@ -86,7 +86,7 @@ class HistoryViewController: ArticleFetchedResultsViewController { Task { do { - let dataController = try WMFWikiWrappedDataController() + let dataController = try WMFPageViewsDataController() try await dataController.deletePageView(title: title, namespaceID: 0, project: project) } catch { DDLogError("Failure deleting WMFData WMFPageViews: \(error)") From a7e6b3640bf0dfb841c5caed43a1a8c2870998c4 Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Mon, 30 Sep 2024 16:55:41 -0500 Subject: [PATCH 28/30] Add WMFPageViewsDataControllerTests --- .../WMFPageViewsDataControllerTests.swift | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 WMFData/Tests/WMFDataTests/WMFPageViewsDataControllerTests.swift diff --git a/WMFData/Tests/WMFDataTests/WMFPageViewsDataControllerTests.swift b/WMFData/Tests/WMFDataTests/WMFPageViewsDataControllerTests.swift new file mode 100644 index 00000000000..4ea35bf6bed --- /dev/null +++ b/WMFData/Tests/WMFDataTests/WMFPageViewsDataControllerTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import WMFData +import CoreData + +final class WMFPageViewsDataControllerTests: XCTestCase { + + enum WMFCoreDataStoreTestsError: Error { + case empty + } + + lazy var store: WMFCoreDataStore = { + let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + return try! WMFCoreDataStore(appContainerURL: temporaryDirectory) + }() + + lazy var dataController: WMFPageViewsDataController = { + let dataController = try! WMFPageViewsDataController(coreDataStore: store) + return dataController + }() + + lazy var enProject: WMFProject = { + let language = WMFLanguage(languageCode: "en", languageVariantCode: nil) + return .wikipedia(language) + }() + + lazy var esProject: WMFProject = { + let language = WMFLanguage(languageCode: "es", languageVariantCode: nil) + return .wikipedia(language) + }() + + lazy var nowDate: Date = { + return Calendar.current.startOfDay(for: Date()) + }() + + lazy var yesterdayDate: Date = { + let dayInSeconds = TimeInterval(60 * 60 * 24) + return nowDate.addingTimeInterval(-dayInSeconds) + }() + + override func setUp() async throws { + _ = self.store + // Wait for store to load asyncronously + try await Task.sleep(nanoseconds: 1_000_000_000) + } + + func testAddPageView() async throws { + + try await dataController.addPageView(title: "Cat", namespaceID: 0, project: enProject) + + // Fetch, confirm page view was added + let results = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(results) + XCTAssertEqual(results!.count, 1) + XCTAssertNotNil(results![0].page) + XCTAssertNotNil(results![0].timestamp) + XCTAssertNotNil(results![0].page) + XCTAssertEqual(results![0].page!.title, "Cat") + XCTAssertEqual(results![0].page!.namespaceID, 0) + XCTAssertEqual(results![0].page!.projectID, "wikipedia~en") + XCTAssertNotNil(results![0].page?.timestamp) + } + + func testDeletePageView() async throws { + + // First add page view + try await dataController.addPageView(title: "Cat", namespaceID: 0, project: enProject) + + // Fetch, confirm page view was added + let addedResults = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(addedResults) + XCTAssertEqual(addedResults!.count, 1) + + // Then delete page view + try await dataController.deletePageView(title: "Cat", namespaceID: 0, project: enProject) + + // Fetch, confirm page view was deleted + let deletedResults = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(deletedResults) + XCTAssertEqual(deletedResults!.count, 0) + } + + func testDeleteAllPageViews() async throws { + // First add page view + try await dataController.addPageView(title: "Cat", namespaceID: 0, project: enProject) + + // Fetch, confirm page view was added + let addedResults = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(addedResults) + XCTAssertEqual(addedResults!.count, 1) + + // Then delete page view + try await dataController.deleteAllPageViews() + + // Fetch, confirm page view was deleted + let deletedResults = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(deletedResults) + XCTAssertEqual(deletedResults!.count, 0) + } + + func testImportPageViews() async throws { + let importRequests: [WMFPageViewImportRequest] = [ + WMFPageViewImportRequest(title: "Cat", project: enProject, viewedDate: nowDate), + WMFPageViewImportRequest(title: "Felis silvestris catus", project: esProject, viewedDate: yesterdayDate) + ] + + try await dataController.importPageViews(requests: importRequests) + + // Fetch, confirm page views were added + let pageViews = try store.fetch(entityType: CDPageView.self, entityName: "WMFPageView", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(pageViews) + XCTAssertEqual(pageViews!.count, 2) + + // Fetch, confirm pages were added + let pages = try store.fetch(entityType: CDPage.self, entityName: "WMFPage", predicate: nil, fetchLimit: nil, in: store.viewContext) + XCTAssertNotNil(pages) + XCTAssertEqual(pages!.count, 2) + } + + func testFetchPageViewCounts() async throws { + + // First add page views + try await dataController.addPageView(title: "Cat", namespaceID: 0, project: enProject) + try await dataController.addPageView(title: "Cat", namespaceID: 0, project: enProject) + try await dataController.addPageView(title: "Felis silvestris catus", namespaceID: 0, project: esProject) + + let results = try dataController.fetchPageViewCounts() + + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results[0].page.title, "Cat") + XCTAssertEqual(results[0].count, 2) + XCTAssertEqual(results[1].page.title, "Felis_silvestris_catus") + XCTAssertEqual(results[1].count, 1) + } +} From 056ea559bbf3790c12a67e01f4289bc65513fe7a Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 1 Oct 2024 09:06:48 -0500 Subject: [PATCH 29/30] Use preferred feature phrasing --- .../Code/ArticleViewController+ArticleWebMessageHandling.swift | 2 +- Wikipedia/Code/ArticleViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift index a2e93e52933..9d35acaef08 100644 --- a/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift +++ b/Wikipedia/Code/ArticleViewController+ArticleWebMessageHandling.swift @@ -74,7 +74,7 @@ extension ArticleViewController: ArticleWebMessageHandling { assignScrollStateFromArticleFlagsIfNecessary() articleLoadWaitGroup?.leave() addToHistory() - persistPageViewsForYearInReview() + persistPageViewsForWikipediaInReview() syncCachedResourcesIfNeeded() } diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index e1ec3484df0..1cd29935091 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -761,7 +761,7 @@ class ArticleViewController: ViewController, HintPresenting { try? article.addToReadHistory() } - func persistPageViewsForYearInReview() { + func persistPageViewsForWikipediaInReview() { if let title = self.articleURL.wmf_title, let namespace = self.articleURL.namespace, let siteURL = self.articleURL.wmf_site, From 9414947c18cb9e32ef8d711f2a1493a6955bf08f Mon Sep 17 00:00:00 2001 From: Toni Sevener Date: Tue, 1 Oct 2024 15:22:07 -0500 Subject: [PATCH 30/30] Remove unused persistent history tracking --- WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift index 18f68e271e2..0d5af5217a3 100644 --- a/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift +++ b/WMFData/Sources/WMFData/Store/WMFCoreDataStore.swift @@ -34,7 +34,6 @@ public final class WMFCoreDataStore { description.shouldAddStoreAsynchronously = true description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) let container = NSPersistentContainer(name: dataModelName, managedObjectModel: dataModel) container.persistentStoreDescriptions = [description] @@ -159,11 +158,6 @@ public final class WMFCoreDataStore { let backgroundContext = try newBackgroundContext try await backgroundContext.perform { - // Delete unused (for now) transaction history > 7 days ago - let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgoDate) - - try backgroundContext.execute(deleteHistoryRequest) - // Delete WMFPageViews that were added > one year ago let predicate = NSPredicate(format: "timestamp < %@", argumentArray: [oneYearAgoDate]) let pageViewFetchRequest = NSFetchRequest(entityName: "WMFPageView")