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,11 +57,14 @@ 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) {
Task {
await ZimMigration.forCustomApps()
navigation.navigateToMostRecentTab() navigation.navigateToMostRecentTab()
} }
} }
} }
} }
}
.commands { .commands {
CommandGroup(replacing: .undoRedo) { CommandGroup(replacing: .undoRedo) {
NavigationCommands() NavigationCommands()

View File

@ -166,11 +166,14 @@ 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) {
Task {
await ZimMigration.forCustomApps()
navigation.currentItem = .reading navigation.currentItem = .reading
} }
} }
} }
} }
}
} }
// MARK: helpers to capture the window // MARK: helpers to capture the window

View File

@ -137,14 +137,22 @@ 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
await MainActor.run { [snapshot] in
dataSource.apply(
snapshot,
to: .tabs,
animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0
) {
// [iOS 15] when a tab is selected, reload it to refresh title and icon // [iOS 15] when a tab is selected, reload it to refresh title and icon
guard #unavailable(iOS 16), guard #unavailable(iOS 16),
let indexPath = self.collectionView.indexPathsForSelectedItems?.first, let indexPath = self.collectionView.indexPathsForSelectedItems?.first,
@ -155,6 +163,8 @@ class SidebarViewController: UICollectionViewController, NSFetchedResultsControl
self.dataSource.apply(snapshot, animatingDifferences: true) self.dataSource.apply(snapshot, animatingDifferences: true)
} }
} }
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let splitViewController = splitViewController as? SplitViewController, guard let splitViewController = splitViewController as? SplitViewController,

View File

@ -45,10 +45,12 @@ 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 {
await MainActor.run {
onComplete?() onComplete?()
} }
} }
}
return metadata return metadata
} }

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,11 +123,19 @@ 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
Task { [weak self] in
await MainActor.run { [weak self] in
self?.canGoBack = webView.canGoBack self?.canGoBack = webView.canGoBack
} }
}
}
canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in
Task { [weak self] in
await MainActor.run { [weak self] in
self?.canGoForward = webView.canGoForward self?.canGoForward = webView.canGoForward
} }
}
}
titleURLObserver = Publishers.CombineLatest( titleURLObserver = Publishers.CombineLatest(
webView.publisher(for: \.title, options: .initial), webView.publisher(for: \.title, options: .initial),
webView.publisher(for: \.url, options: .initial) webView.publisher(for: \.url, 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 ?? ""
Task {
await MainActor.run {
self.url = url 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,9 +211,28 @@ 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
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 url = webView.url
} }
} }
}
}
}
// MARK: - WKNavigationDelegate // MARK: - WKNavigationDelegate

View File

@ -30,8 +30,13 @@ 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()
Task {
await MainActor.run {
currentItem = NavigationItem.tab(objectID: tab.objectID) currentItem = NavigationItem.tab(objectID: tab.objectID)
} }
}
}
@discardableResult @discardableResult
func createTab() -> NSManagedObjectID { func createTab() -> NSManagedObjectID {