Merge pull request #688 from kiwix/601-custom-apps-bookmark-migration

Custom apps ZIM migration
This commit is contained in:
Kelson 2024-03-12 19:49:56 +01:00 committed by GitHub
commit 032a86cefb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 191 additions and 22 deletions

View File

@ -57,7 +57,10 @@ struct Kiwix: App {
DownloadService.shared.restartHeartbeatIfNeeded() DownloadService.shared.restartHeartbeatIfNeeded()
case let .custom(zimFileURL): case let .custom(zimFileURL):
LibraryOperations.open(url: zimFileURL) { LibraryOperations.open(url: zimFileURL) {
navigation.navigateToMostRecentTab() Task {
await ZimMigration.forCustomApps()
navigation.navigateToMostRecentTab()
}
} }
} }
} }

View File

@ -166,7 +166,10 @@ struct RootView: View {
DownloadService.shared.restartHeartbeatIfNeeded() DownloadService.shared.restartHeartbeatIfNeeded()
case let .custom(zimFileURL): case let .custom(zimFileURL):
LibraryOperations.open(url: zimFileURL) { LibraryOperations.open(url: zimFileURL) {
navigation.currentItem = .reading Task {
await ZimMigration.forCustomApps()
navigation.currentItem = .reading
}
} }
} }
} }

View File

@ -137,22 +137,32 @@ class SidebarViewController: UICollectionViewController, NSFetchedResultsControl
// MARK: - Delegations // MARK: - Delegations
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, nonisolated func controller(
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { _ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
let tabs = snapshot.itemIdentifiers let tabs = snapshot.itemIdentifiers
.compactMap { $0 as? NSManagedObjectID } .compactMap { $0 as? NSManagedObjectID }
.map { NavigationItem.tab(objectID: $0) } .map { NavigationItem.tab(objectID: $0) }
var snapshot = NSDiffableDataSourceSectionSnapshot<NavigationItem>() var snapshot = NSDiffableDataSourceSectionSnapshot<NavigationItem>()
snapshot.append(tabs) snapshot.append(tabs)
dataSource.apply(snapshot, to: .tabs, animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0) { Task { [snapshot] in
// [iOS 15] when a tab is selected, reload it to refresh title and icon await MainActor.run { [snapshot] in
guard #unavailable(iOS 16), dataSource.apply(
let indexPath = self.collectionView.indexPathsForSelectedItems?.first, snapshot,
let item = self.dataSource.itemIdentifier(for: indexPath), to: .tabs,
case .tab = item else { return } animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0
var snapshot = self.dataSource.snapshot() ) {
snapshot.reconfigureItems([item]) // [iOS 15] when a tab is selected, reload it to refresh title and icon
self.dataSource.apply(snapshot, animatingDifferences: true) guard #unavailable(iOS 16),
let indexPath = self.collectionView.indexPathsForSelectedItems?.first,
let item = self.dataSource.itemIdentifier(for: indexPath),
case .tab = item else { return }
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems([item])
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
} }
} }

View File

@ -45,8 +45,10 @@ struct LibraryOperations {
zimFile.fileURLBookmark = fileURLBookmark zimFile.fileURLBookmark = fileURLBookmark
zimFile.isMissing = false zimFile.isMissing = false
if context.hasChanges { try? context.save() } if context.hasChanges { try? context.save() }
DispatchQueue.main.async { Task {
onComplete?() await MainActor.run {
onComplete?()
}
} }
} }

View File

@ -0,0 +1,95 @@
//
// ZimMigration.swift
// Kiwix
import Foundation
import CoreData
enum ZimMigration {
/// Holds the new zimfile host:
/// Set during migration,
/// and read back when updating URLS mapped from WebView interaction state,
/// witch is saved as Data for each opened Tab
@MainActor private static var newHost: String?
private static let sortDescriptors = [NSSortDescriptor(keyPath: \ZimFile.created, ascending: true)]
private static let requestLatestZimFile = ZimFile.fetchRequest(
predicate: ZimFile.Predicate.isDownloaded,
sortDescriptors: Self.sortDescriptors
)
static func forCustomApps() async {
guard FeatureFlags.hasLibrary == false else { return }
await Database.shared.container.performBackgroundTask { context in
guard var zimFiles = try? requestLatestZimFile.execute(),
zimFiles.count > 1,
let latest = zimFiles.popLast() else {
return
}
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
for zimFile in zimFiles {
migrateFrom(zimFile: zimFile, toZimFile: latest, using: context)
}
}
}
static func customApp(url: URL) async -> URL {
let newHost = await latestZimFileHost()
guard let newURL = url.updateHost(to: newHost) else {
assertionFailure("url cannot be updated")
return url
}
return newURL
}
/// Migrates the bookmars from an old to new zim file,
/// also updates the bookmark urls accordingly (based on the new zim id as the host of those URLs)
/// deletes the old zim file in the DB
private static func migrateFrom(
zimFile fromZim: ZimFile,
toZimFile toZim: ZimFile,
using context: NSManagedObjectContext
) {
let newHost = toZim.fileID.uuidString
Task {
await MainActor.run {
Self.newHost = newHost
}
}
fromZim.bookmarks.forEach { (bookmark: Bookmark) in
bookmark.zimFile = toZim
if let newArticleURL = bookmark.articleURL.updateHost(to: newHost) {
bookmark.articleURL = newArticleURL
}
bookmark.thumbImageURL = bookmark.thumbImageURL?.updateHost(to: newHost)
}
fromZim.tabs.forEach { (tab: Tab) in
tab.zimFile = toZim
}
context.delete(fromZim)
if context.hasChanges { try? context.save() }
}
private static func latestZimFileHost() async -> String {
if let newHost = await Self.newHost { return newHost }
// if it wasn't set before, set and return by the last ZimFile in DB:
guard let zimFile = try? requestLatestZimFile.execute().first else {
fatalError("we should have at least 1 zim file for a custom app")
}
let newHost = zimFile.fileID.uuidString
// save the new host for later
await MainActor.run {
Self.newHost = newHost
}
return newHost
}
}
extension URL {
func updateHost(to newHost: String) -> URL? {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil }
components.host = newHost
return components.url
}
}

View File

@ -0,0 +1,16 @@
//
// BookmarkMigrationTests.swift
// UnitTests
import XCTest
@testable import Kiwix
final class BookmarkMigrationTests: XCTestCase {
func testURLHostChange() throws {
let url = URL(string: "kiwix://64C3EA1A-5161-2B94-1F50-606DA5EC0035/wb/Saftladen")!
let newHost: String = UUID(uuidString: "A992BF76-CA94-6B60-A762-9B5BC89B5BBF")!.uuidString
let expectedURL = URL(string: "kiwix://A992BF76-CA94-6B60-A762-9B5BC89B5BBF/wb/Saftladen")!
XCTAssertEqual(url.updateHost(to: newHost), expectedURL)
}
}

View File

@ -22,6 +22,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
NSFetchedResultsControllerDelegate { NSFetchedResultsControllerDelegate {
private static var cache = OrderedDictionary<NSManagedObjectID, BrowserViewModel>() private static var cache = OrderedDictionary<NSManagedObjectID, BrowserViewModel>()
@MainActor
static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel {
let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID) let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID)
cache.removeValue(forKey: tabID) cache.removeValue(forKey: tabID)
@ -47,7 +48,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
@Published private(set) var articleBookmarked = false @Published private(set) var articleBookmarked = false
@Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItems = [OutlineItem]()
@Published private(set) var outlineItemTree = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]()
@Published private(set) var url: URL? { @MainActor @Published private(set) var url: URL? {
didSet { didSet {
if !FeatureFlags.hasLibrary, url == nil { if !FeatureFlags.hasLibrary, url == nil {
loadMainArticle() loadMainArticle()
@ -84,6 +85,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
// MARK: - Lifecycle // MARK: - Lifecycle
@MainActor
init(tabID: NSManagedObjectID? = nil) { init(tabID: NSManagedObjectID? = nil) {
self.tabID = tabID self.tabID = tabID
webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) webView = WKWebView(frame: .zero, configuration: WebViewConfiguration())
@ -121,10 +123,18 @@ final class BrowserViewModel: NSObject, ObservableObject,
// setup web view property observers // setup web view property observers
canGoBackObserver = webView.observe(\.canGoBack, options: .initial) { [weak self] webView, _ in canGoBackObserver = webView.observe(\.canGoBack, options: .initial) { [weak self] webView, _ in
self?.canGoBack = webView.canGoBack Task { [weak self] in
await MainActor.run { [weak self] in
self?.canGoBack = webView.canGoBack
}
}
} }
canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in
self?.canGoForward = webView.canGoForward Task { [weak self] in
await MainActor.run { [weak self] in
self?.canGoForward = webView.canGoForward
}
}
} }
titleURLObserver = Publishers.CombineLatest( titleURLObserver = Publishers.CombineLatest(
webView.publisher(for: \.title, options: .initial), webView.publisher(for: \.title, options: .initial),
@ -146,7 +156,11 @@ final class BrowserViewModel: NSObject, ObservableObject,
// update view model // update view model
articleTitle = title articleTitle = title
zimFileName = zimFile?.name ?? "" zimFileName = zimFile?.name ?? ""
self.url = url Task {
await MainActor.run {
self.url = url
}
}
let currentTabID: NSManagedObjectID = tabID ?? createNewTabID() let currentTabID: NSManagedObjectID = tabID ?? createNewTabID()
tabID = currentTabID tabID = currentTabID
@ -173,19 +187,21 @@ final class BrowserViewModel: NSObject, ObservableObject,
} }
// MARK: - Content Loading // MARK: - Content Loading
@MainActor
func load(url: URL) { func load(url: URL) {
guard webView.url != url else { return } guard webView.url != url else { return }
webView.load(URLRequest(url: url)) webView.load(URLRequest(url: url))
self.url = url self.url = url
} }
@MainActor
func loadRandomArticle(zimFileID: UUID? = nil) { func loadRandomArticle(zimFileID: UUID? = nil) {
let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "")
guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return }
load(url: url) load(url: url)
} }
@MainActor
func loadMainArticle(zimFileID: UUID? = nil) { func loadMainArticle(zimFileID: UUID? = nil) {
let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "")
guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return }
@ -195,7 +211,26 @@ final class BrowserViewModel: NSObject, ObservableObject,
private func restoreBy(tabID: NSManagedObjectID) { private func restoreBy(tabID: NSManagedObjectID) {
if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab {
webView.interactionState = tab.interactionState webView.interactionState = tab.interactionState
url = webView.url if AppType.isCustom {
Task {
guard let webURL = await webView.url else {
await MainActor.run {
url = nil
}
return
}
let newURL = await ZimMigration.customApp(url: webURL)
await MainActor.run {
url = newURL
}
}
} else {
Task {
await MainActor.run {
url = webView.url
}
}
}
} }
} }

View File

@ -30,7 +30,12 @@ class NavigationViewModel: ObservableObject {
let tab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context) let tab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context)
try? context.obtainPermanentIDs(for: [tab]) try? context.obtainPermanentIDs(for: [tab])
try? context.save() try? context.save()
currentItem = NavigationItem.tab(objectID: tab.objectID) Task {
await MainActor.run {
currentItem = NavigationItem.tab(objectID: tab.objectID)
}
}
} }
@discardableResult @discardableResult