Skip to content

Commit

Permalink
Merge pull request #4999 from wikimedia/viewed-articles-coredata
Browse files Browse the repository at this point in the history
Add Core Data stack to WMFData and persist page views
  • Loading branch information
mazevedofs authored Oct 3, 2024
2 parents f4f60cd + 11105d8 commit b5cdbbd
Show file tree
Hide file tree
Showing 15 changed files with 915 additions and 3 deletions.
58 changes: 58 additions & 0 deletions WMF Framework/MWKDataStore+WMFPageViewImport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import WMFData
import CocoaLumberjackSwift

extension MWKDataStore {
@objc func importViewedArticlesIntoWMFData(dataStoreMOC: NSManagedObjectContext) {
guard let dataController = try? WMFPageViewsDataController() else {
return
}

let currentYear = Calendar.current.component(.year, from: Date())
var dateComponents = DateComponents()
dateComponents.year = currentYear
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)
} catch {
DDLogError("Error importing WMFPageViewImportRequests: \(error)")
}
}


} catch {
DDLogError("Error fetching viewed WMFArticles: \(error)")
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import Foundation
import CoreData

public final class WMFPage {
public let namespaceID: Int
public 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 {
public let timestamp: Date
public let page: WMFPage

init(timestamp: Date, page: WMFPage) {
self.timestamp = timestamp
self.page = page
}
}

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 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 WMFPageViewsDataController {

private let coreDataStore: WMFCoreDataStore

public init(coreDataStore: WMFCoreDataStore? = WMFDataEnvironment.current.coreDataStore) throws {

guard let coreDataStore else {
throw WMFDataControllerError.coreDataStoreUnavailable
}

self.coreDataStore = coreDataStore
}

public func addPageView(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 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 = 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 = currentDate

try self.coreDataStore.saveIfNeeded(moc: backgroundContext)
}
}

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 {
let pageViewFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "WMFPageView")

let batchPageViewDeleteRequest = NSBatchDeleteRequest(fetchRequest: pageViewFetchRequest)
batchPageViewDeleteRequest.resultType = .resultTypeObjectIDs
_ = try backgroundContext.execute(batchPageViewDeleteRequest) as? NSBatchDeleteResult

backgroundContext.refreshAllObjects()
}
}

public func importPageViews(requests: [WMFPageViewImportRequest]) async throws {

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
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 page = viewContext.object(with: objectID) as? CDPage,
let projectID = page.projectID, let title = page.title else {
continue
}

let namespaceID = page.namespaceID

pageViewCounts.append(WMFPageViewCount(page: WMFPage(namespaceID: Int(namespaceID), projectID: projectID, title: title), count: count))
}
return pageViewCounts
}

return results
}
}
2 changes: 2 additions & 0 deletions WMFData/Sources/WMFData/Environment/WMFDataEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])

Expand All @@ -25,4 +26,5 @@ public final class WMFDataEnvironment: ObservableObject {

public internal(set) var userDefaultsStore: WMFKeyValueStore? = WMFUserDefaultsStore()
public var sharedCacheStore: WMFKeyValueStore?
public var coreDataStore: WMFCoreDataStore?
}
10 changes: 10 additions & 0 deletions WMFData/Sources/WMFData/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
public enum WMFDataControllerError: LocalizedError {
case mediaWikiServiceUnavailable
case basicServiceUnavailable
case coreDataStoreUnavailable
case failureCreatingRequestURL
case unexpectedResponse
case serviceError(Error)
Expand All @@ -26,6 +27,15 @@ public enum WMFUserDefaultsStoreError: Error {
case failureEncodingJSON(Error)
}

enum WMFCoreDataStoreError: Error {
case setupMissingAppContainerURL
case setupMissingDataModelFileURL
case setupMissingDataModel
case setupMissingPersistentContainer
case missingEntity
case unexpectedFetchGroupResult
}

public enum WMFDonateDataControllerError: LocalizedError {
case paymentsWikiResponseError(reason: String?, orderID: String?)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="WMFPage" representedClassName="CDPage" syncable="YES" codeGenerationType="class">
<attribute name="namespaceID" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="projectID" attributeType="String"/>
<attribute name="timestamp" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="title" attributeType="String"/>
<relationship name="pageViews" toMany="YES" deletionRule="Cascade" destinationEntity="WMFPageView" inverseName="page" inverseEntity="WMFPageView"/>
<fetchIndex name="byProjectNamespace">
<fetchIndexElement property="projectID" type="Binary" order="ascending"/>
<fetchIndexElement property="namespaceID" type="Binary" order="ascending"/>
</fetchIndex>
<fetchIndex name="byProjectNamespaceTitle">
<fetchIndexElement property="projectID" type="Binary" order="ascending"/>
<fetchIndexElement property="namespaceID" type="Binary" order="ascending"/>
<fetchIndexElement property="title" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<entity name="WMFPageView" representedClassName="CDPageView" syncable="YES" codeGenerationType="class">
<attribute name="timestamp" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="page" maxCount="1" deletionRule="Cascade" destinationEntity="WMFPage" inverseName="pageViews" inverseEntity="WMFPage"/>
</entity>
</model>
Loading

0 comments on commit b5cdbbd

Please sign in to comment.