-
-
Notifications
You must be signed in to change notification settings - Fork 759
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4999 from wikimedia/viewed-articles-coredata
Add Core Data stack to WMFData and persist page views
- Loading branch information
Showing
15 changed files
with
915 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)") | ||
} | ||
|
||
} | ||
|
||
} |
187 changes: 187 additions & 0 deletions
187
WMFData/Sources/WMFData/Data Controllers/Shared/WMFPageViewsDataController.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
23 changes: 23 additions & 0 deletions
23
WMFData/Sources/WMFData/Resources/WMFData.xcdatamodeld/WMFData.xcdatamodel/contents
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.