mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-09-26 05:18:31 -04:00
Merge pull request #688 from kiwix/601-custom-apps-bookmark-migration
Custom apps ZIM migration
This commit is contained in:
commit
032a86cefb
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
95
SwiftUI/Model/ZimMigration.swift
Normal file
95
SwiftUI/Model/ZimMigration.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
16
Tests/BookmarkMigrationTests.swift
Normal file
16
Tests/BookmarkMigrationTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user