mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-08-04 13:07:04 -04:00

* coredata stack * rename fir * move files * library setup * compatibility * library setup * load on appear * ZimFileGrid * FlavorTag * withCheckedThrowingContinuation * flavor tag * zim files cell * grid * list setup * zim file list * grid * zim file cell background * favicon * SectionHeader * grid * new tab * list style * refactor * refactor * style * zim file grid * searchable new tab * new section * style * ZimFileCell * cache favicon * ZimFilesNew * ZimFilesNew sorting filtering * ZimFilesNew * library content * keypath * ZimFileList searchable * ZimFileList * ZimFilesNew side panel * animation * backward compatible article count * ZimFileList deterministic sort order * rename * macos styling * library grid * grid * refactor * grid * zim file list * MacAdaptableContent * ZimFileCellSelection * grid * zim file detail * refactor * move file * DownloadTask * downloads * downloadURL * start download * Download task * observable * zim file basic info * download progress * rename * refactor * pause resume * save file * download error * refactor * refactor * macos zim file detail * iOS simplier navigation link * refactor * refactor * refactor * ZimFilesNew * ZimFilesNew * ZimFilesNew * ZimFileGrid * ZimFileList * zim file list * ZimFileListStyle * ZimFileRow * ZimFileRowSelection * ZimFileCellSelection * purge * availability * download * ZimFileDetail * ZimFileContextMenu * scheme * ZimFileSelection * ZimFileGrid * iOS root * images * favicon * Favicon * asset * File import button * ZimFilesOpened * upsertZimFile * FileImporter * zim file opened * ZimFilesOpened * open zim file help * actions * ZimFileDetail actions * ZimFileDetail download * ZimFileDetail iOS * ZimFileDetail alerts * isFileImporterPresented * delete action iOS * iOS 14+ UIKit Target * scene based iOS app * consolidation * delete * iOSApp * delete * iOS info plist * info plist * preview content * rename * WebView * swiftui based scene * file open * open url * load main page * refactor * macOS build * LibraryViewModel * doc * refactor * buttons * opened action * refactor * refactor * open main page * Reader - Webview * buttons * appearance * buttons * BookmarkButton * refactor * bookmarks * WKNavigationDelegate * ReaderViewModel * more button * MoreButton * disable * views * button * buttons * reader * reader * reader * sidebar * focus & commands * display mode * SidebarZimFilesOpened * url * SidebarZimFilesOpened * SidebarZimFilesOpened * ios webview * main page * MainArticleButton * RandomArticleButton * inject * outline * refactor * outline * sheetDisplayMode * OutlineButton * Outline dismiss * macOS * purge * Outline * iOS sidebar width * BookmarkButton * BookmarkButton * bookmarks * Outlint * Bookmarks * issearchactive * ios search active * search results * Search * SearchFilter * search macos * search * Search * app icon * compatibility * SearchViewModel * refactor * search result * refactor sort * search operation * search view model * mac search result loading * mac remove unused * ios app icon * SearchResultSnippetMode * build * move file * Search ios * search result loading * prevent search result filckering * ignoresSafeArea * default sidebar display mode * list style * search sidebar width * SearchResultCell * SearchResultCell * SearchResultRow * SearchResultRow line limit * search result cell * SearchResultRow * BookmarkButton * webview gesture * macos ControlGroup * CellBackground * purge * outline style * welcome view * outline * SplitView * SplitView * welcome url * BookmarkButton tap * bookmark toggle * Search mac * Welcome grid item * welcome * revert * animation * animation * animation * iphone regular sidebar
223 lines
10 KiB
Swift
223 lines
10 KiB
Swift
//
|
|
// Database.swift
|
|
// Kiwix
|
|
//
|
|
// Created by Chris Li on 12/23/21.
|
|
// Copyright © 2022 Chris Li. All rights reserved.
|
|
//
|
|
|
|
import CoreData
|
|
|
|
class Database {
|
|
static let shared = Database()
|
|
private var notificationToken: NSObjectProtocol?
|
|
private var token: NSPersistentHistoryToken?
|
|
private var tokenURL = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("token.data")
|
|
|
|
private init() {
|
|
notificationToken = NotificationCenter.default.addObserver(
|
|
forName: .NSPersistentStoreRemoteChange, object: nil, queue: nil) { notification in
|
|
try? self.mergeChanges()
|
|
}
|
|
token = {
|
|
guard let data = UserDefaults.standard.data(forKey: "PersistentHistoryToken") else { return nil }
|
|
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: data)
|
|
}()
|
|
}
|
|
|
|
deinit {
|
|
if let token = notificationToken {
|
|
NotificationCenter.default.removeObserver(token)
|
|
}
|
|
}
|
|
|
|
/// A persistent container to set up the Core Data stack.
|
|
lazy var container: NSPersistentContainer = {
|
|
/// - Tag: persistentContainer
|
|
let container = NSPersistentContainer(name: "DataModel")
|
|
|
|
guard let description = container.persistentStoreDescriptions.first else {
|
|
fatalError("Failed to retrieve a persistent store description.")
|
|
}
|
|
|
|
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
|
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
|
|
container.loadPersistentStores { storeDescription, error in
|
|
if let error = error as NSError? {
|
|
fatalError("Unresolved error \(error), \(error.userInfo)")
|
|
}
|
|
}
|
|
|
|
// This sample refreshes UI by consuming store changes via persistent history tracking.
|
|
/// - Tag: viewContextMergeParentChanges
|
|
container.viewContext.automaticallyMergesChangesFromParent = false
|
|
container.viewContext.name = "viewContext"
|
|
/// - Tag: viewContextMergePolicy
|
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
container.viewContext.undoManager = nil
|
|
container.viewContext.shouldDeleteInaccessibleFaults = true
|
|
return container
|
|
}()
|
|
|
|
/// Create or update a single zim file entry in the local database.
|
|
func upsertZimFile(metadata: ZimFileMetaData, fileURLBookmark: Data?) {
|
|
let context = container.newBackgroundContext()
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
context.undoManager = nil
|
|
context.perform {
|
|
let predicate = NSPredicate(format: "fileID == %@", metadata.fileID as CVarArg)
|
|
let fetchRequest = ZimFile.fetchRequest(predicate: predicate)
|
|
guard let zimFile = try? context.fetch(fetchRequest).first ?? ZimFile(context: context) else { return }
|
|
self.configureZimFile(zimFile, metadata: metadata)
|
|
zimFile.fileURLBookmark = fileURLBookmark
|
|
if context.hasChanges { try? context.save() }
|
|
}
|
|
}
|
|
|
|
/// Batch update the local zim file database with what's available online.
|
|
func refreshZimFileCatalog() async throws {
|
|
guard let url = URL(string: "https://library.kiwix.org/catalog/root.xml") else { return }
|
|
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
|
URLSession.shared.dataTask(with: url) { data, response, error in
|
|
guard let response = response as? HTTPURLResponse, response.statusCode == 200, let data = data else {
|
|
let error = OPDSRefreshError.retrieve(description: "Error retrieving online catalog.")
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
continuation.resume(returning: data)
|
|
}.resume()
|
|
}
|
|
|
|
let parser = OPDSStreamParser()
|
|
try parser.parse(data: data)
|
|
|
|
// create context
|
|
let context = container.newBackgroundContext()
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
context.undoManager = nil
|
|
|
|
// insert new zim files
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
|
|
context.performAndWait {
|
|
do {
|
|
let existing = try context.fetch(ZimFile.fetchRequest()).map { $0.fileID.uuidString.lowercased() }
|
|
var zimFileIDs = Set(parser.zimFileIDs).subtracting(existing)
|
|
let insertRequest = NSBatchInsertRequest(
|
|
entity: ZimFile.entity(),
|
|
managedObjectHandler: { zimFile in
|
|
guard let zimFile = zimFile as? ZimFile else { return true }
|
|
while !zimFileIDs.isEmpty {
|
|
guard let id = zimFileIDs.popFirst(),
|
|
let metadata = parser.getZimFileMetaData(id: id) else { continue }
|
|
self.configureZimFile(zimFile, metadata: metadata)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
)
|
|
insertRequest.resultType = .count
|
|
guard let result = try context.execute(insertRequest) as? NSBatchInsertResult,
|
|
let count = result.result as? Int else { throw OPDSRefreshError.process }
|
|
print("Added \(count) zim files entities.")
|
|
} catch {
|
|
continuation.resume(throwing: OPDSRefreshError.process)
|
|
}
|
|
}
|
|
continuation.resume()
|
|
}
|
|
|
|
// delete old zim files
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
|
|
context.performAndWait {
|
|
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = ZimFile.fetchRequest()
|
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
|
NSPredicate(format: "fileURLBookmark == nil"),
|
|
NSPredicate(format: "NOT fileID IN %@", parser.zimFileIDs.compactMap { UUID(uuidString: $0) })
|
|
])
|
|
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
|
deleteRequest.resultType = .resultTypeCount
|
|
do {
|
|
guard let result = try context.execute(deleteRequest) as? NSBatchDeleteResult,
|
|
let count = result.result as? Int else { throw OPDSRefreshError.process }
|
|
print("Deleted \(count) zim files entities.")
|
|
} catch {
|
|
continuation.resume(throwing: OPDSRefreshError.process)
|
|
}
|
|
}
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
/// Save image data to zim files.
|
|
func saveImageData(url: URL, completion: @escaping (Data) -> Void) {
|
|
URLSession.shared.dataTask(with: url) { data, response, error in
|
|
guard let response = response as? HTTPURLResponse,
|
|
response.statusCode == 200,
|
|
let mimeType = response.mimeType,
|
|
mimeType.contains("image"),
|
|
let data = data else { return }
|
|
let context = self.container.newBackgroundContext()
|
|
context.perform {
|
|
let predicate = NSPredicate(format: "faviconURL == %@", url as CVarArg)
|
|
let request = ZimFile.fetchRequest(predicate: predicate)
|
|
guard let zimFile = try? context.fetch(request).first else { return }
|
|
zimFile.faviconData = data
|
|
try? context.save()
|
|
}
|
|
completion(data)
|
|
}.resume()
|
|
}
|
|
|
|
/// Configure a zim file object based on its metadata.
|
|
private func configureZimFile(_ zimFile: ZimFile, metadata: ZimFileMetaData) {
|
|
zimFile.articleCount = metadata.articleCount.int64Value
|
|
zimFile.category = metadata.category
|
|
zimFile.created = metadata.creationDate
|
|
zimFile.fileDescription = metadata.fileDescription
|
|
zimFile.fileID = metadata.fileID
|
|
zimFile.flavor = metadata.flavor
|
|
zimFile.hasDetails = metadata.hasDetails
|
|
zimFile.hasPictures = metadata.hasPictures
|
|
zimFile.hasVideos = metadata.hasVideos
|
|
zimFile.languageCode = metadata.languageCode
|
|
zimFile.mediaCount = metadata.mediaCount.int64Value
|
|
zimFile.name = metadata.title
|
|
zimFile.persistentID = metadata.groupIdentifier
|
|
zimFile.size = metadata.size.int64Value
|
|
|
|
// Only overwrite favicon data and url if there is a new value
|
|
if let url = metadata.downloadURL { zimFile.downloadURL = url }
|
|
if let url = metadata.faviconURL { zimFile.faviconURL = url }
|
|
}
|
|
|
|
/// Merge changes performed on batch requests to view context.
|
|
private func mergeChanges() throws {
|
|
let context = container.newBackgroundContext()
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
context.undoManager = nil
|
|
context.perform {
|
|
// fetch and merge changes
|
|
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.token)
|
|
guard let result = try? context.execute(fetchRequest) as? NSPersistentHistoryResult,
|
|
let transactions = result.result as? [NSPersistentHistoryTransaction] else { return }
|
|
self.container.viewContext.perform {
|
|
transactions.forEach { transaction in
|
|
self.container.viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
|
|
self.token = transaction.token
|
|
}
|
|
}
|
|
|
|
// update token
|
|
guard let token = transactions.last?.token else { return }
|
|
let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
|
|
UserDefaults.standard.set(data, forKey: "PersistentHistoryToken")
|
|
|
|
// purge history
|
|
let sevenDaysAgo = Date(timeIntervalSinceNow: -3600 * 24 * 7)
|
|
let purgeRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgo)
|
|
_ = try? context.execute(purgeRequest)
|
|
}
|
|
}
|
|
}
|