diff --git a/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift b/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift new file mode 100644 index 000000000000..dafe83fd3e2d --- /dev/null +++ b/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift @@ -0,0 +1,328 @@ +import XCTest +import WordPressData + +@testable import WordPress + +class ReaderSavedPostsExporterTests: CoreDataTestCase { + + private let exporter = ReaderSavedPostsExporter() + + // MARK: - Export + + func testExportReturnsNilWhenNoSavedPosts() async throws { + let result = try await exporter.export(coreDataStack: contextManager) + XCTAssertNil(result) + } + + func testExportReturnsNilWhenPostsExistButNoneAreSaved() async throws { + let post = makeReaderPost() + post.isSavedForLater = false + try mainContext.save() + + let result = try await exporter.export(coreDataStack: contextManager) + XCTAssertNil(result) + } + + func testExportCreatesJSONFileWithSavedPosts() async throws { + let post = makeReaderPost() + post.postTitle = "Test Post" + post.permaLink = "https://example.com/test-post" + post.authorDisplayName = "Jane Doe" + post.blogName = "Example Blog" + post.blogURL = "https://example.com" + post.summary = "A short summary" + post.featuredImage = "https://example.com/image.jpg" + post.tags = "swift, ios" + post.siteID = 12345 + post.postID = 67890 + post.isExternal = false + post.isSavedForLater = true + post.sortDate = Date(timeIntervalSince1970: 1000000) + post.date_created_gmt = Date(timeIntervalSince1970: 1000000) + try mainContext.save() + + let url = try await exporter.export(coreDataStack: contextManager) + let fileURL = try XCTUnwrap(url) + + let data = try Data(contentsOf: fileURL) + let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(envelope["postCount"] as? Int, 1) + XCTAssertNotNil(envelope["exportDate"]) + + let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]]) + XCTAssertEqual(posts.count, 1) + + let exported = posts[0] + XCTAssertEqual(exported["title"] as? String, "Test Post") + XCTAssertEqual(exported["url"] as? String, "https://example.com/test-post") + XCTAssertEqual(exported["author"] as? String, "Jane Doe") + XCTAssertEqual(exported["siteName"] as? String, "Example Blog") + XCTAssertEqual(exported["siteURL"] as? String, "https://example.com") + XCTAssertEqual(exported["summary"] as? String, "A short summary") + XCTAssertEqual(exported["featuredImageURL"] as? String, "https://example.com/image.jpg") + XCTAssertEqual(exported["tags"] as? [String], ["swift", "ios"]) + XCTAssertEqual((exported["siteID"] as? NSNumber)?.intValue, 12345) + XCTAssertEqual((exported["postID"] as? NSNumber)?.intValue, 67890) + XCTAssertEqual(exported["isFeed"] as? Bool, false) + } + + func testExportOnlyIncludesSavedPosts() async throws { + let saved = makeReaderPost() + saved.postTitle = "Saved" + saved.permaLink = "https://example.com/saved" + saved.isSavedForLater = true + saved.sortDate = Date() + + let unsaved = makeReaderPost() + unsaved.postTitle = "Unsaved" + unsaved.permaLink = "https://example.com/unsaved" + unsaved.isSavedForLater = false + unsaved.sortDate = Date() + + try mainContext.save() + + let url = try await exporter.export(coreDataStack: contextManager) + let fileURL = try XCTUnwrap(url) + let data = try Data(contentsOf: fileURL) + let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]]) + + XCTAssertEqual(posts.count, 1) + XCTAssertEqual(posts[0]["title"] as? String, "Saved") + } + + func testExportOmitsEmptyOptionalFields() async throws { + let post = makeReaderPost() + post.permaLink = "https://example.com/minimal" + post.isSavedForLater = true + post.sortDate = Date() + try mainContext.save() + + let url = try await exporter.export(coreDataStack: contextManager) + let fileURL = try XCTUnwrap(url) + let data = try Data(contentsOf: fileURL) + let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]]) + let exported = posts[0] + + XCTAssertNil(exported["featuredImageURL"]) + XCTAssertNil(exported["tags"]) + } + + func testExportFileNameContainsDate() async throws { + let post = makeReaderPost() + post.permaLink = "https://example.com/test" + post.isSavedForLater = true + post.sortDate = Date() + try mainContext.save() + + let url = try await exporter.export(coreDataStack: contextManager) + let fileURL = try XCTUnwrap(url) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let todayString = dateFormatter.string(from: Date()) + + XCTAssertTrue(fileURL.lastPathComponent.contains(todayString)) + XCTAssertEqual(fileURL.pathExtension, "json") + } + + // MARK: - parseExportFile + + func testParseExportFileReturnsPosts() throws { + let envelope = ReaderSavedPostsExporter.Envelope( + exportDate: "2026-04-23", + postCount: 2, + posts: [ + makeExportedPost(url: "https://example.com/1", siteID: 100, postID: 1), + makeExportedPost(url: "https://example.com/2", siteID: 200, postID: 2) + ], + appVersion: "Test 1.0" + ) + let fileURL = try writeEnvelopeToTempFile(envelope) + let posts = try ReaderSavedPostsExporter.parseExportFile(at: fileURL) + + XCTAssertEqual(posts.count, 2) + XCTAssertEqual(posts[0].url, "https://example.com/1") + } + + func testParseExportFileThrowsForInvalidFormat() throws { + let json: [String: Any] = ["notPosts": true] + let fileURL = try writeJSONToTempFile(json) + + XCTAssertThrowsError(try ReaderSavedPostsExporter.parseExportFile(at: fileURL)) + } + + func testParseExportFileThrowsForNonJSON() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("bad-\(UUID().uuidString).json") + try "not json".write(to: fileURL, atomically: true, encoding: .utf8) + + XCTAssertThrowsError(try ReaderSavedPostsExporter.parseExportFile(at: fileURL)) + } + + // MARK: - Import filtering + + func testImportSkipsPostsAlreadySaved() async throws { + let existing = makeReaderPost() + existing.permaLink = "https://example.com/already-saved" + existing.isSavedForLater = true + existing.sortDate = Date() + try mainContext.save() + + let posts = [makeExportedPost(url: "https://example.com/already-saved", siteID: 100, postID: 1)] + + let result = await ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: Progress() + ) + + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + } + + func testImportSkipsPostsWithMissingSiteID() async { + let posts = [makeExportedPost(url: "https://example.com/no-site", siteID: nil, postID: 1)] + + let result = await ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: Progress() + ) + + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + } + + func testImportSkipsPostsWithMissingPostID() async { + let posts = [makeExportedPost(url: "https://example.com/no-post-id", siteID: 100, postID: nil)] + + let result = await ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: Progress() + ) + + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + } + + func testImportSkipsPostsWithEmptyURL() async { + let posts = [makeExportedPost(url: "", siteID: 100, postID: 1)] + + let result = await ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: Progress() + ) + + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + } + + func testImportReturnsEmptyResultForEmptyPostsList() async { + let result = await ReaderSavedPostsExporter.importPosts( + [], + coreDataStack: contextManager, + progress: Progress() + ) + + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 0) + XCTAssertEqual(result.failed, 0) + } + + // MARK: - Round-trip (export -> parse) + + func testExportThenParsePreservesAllFields() async throws { + let post = makeReaderPost() + post.postTitle = "Round Trip" + post.permaLink = "https://example.com/round-trip" + post.authorDisplayName = "Author" + post.blogName = "Blog" + post.blogURL = "https://blog.example.com" + post.summary = "Summary text" + post.featuredImage = "https://example.com/img.jpg" + post.tags = "tag1, tag2" + post.siteID = 999 + post.postID = 888 + post.isExternal = true + post.isSavedForLater = true + post.sortDate = Date() + post.date_created_gmt = Date(timeIntervalSince1970: 1700000000) + try mainContext.save() + + let url = try await exporter.export(coreDataStack: contextManager) + let fileURL = try XCTUnwrap(url) + let posts = try ReaderSavedPostsExporter.parseExportFile(at: fileURL) + + XCTAssertEqual(posts.count, 1) + let exported = posts[0] + XCTAssertEqual(exported.title, "Round Trip") + XCTAssertEqual(exported.url, "https://example.com/round-trip") + XCTAssertEqual(exported.author, "Author") + XCTAssertEqual(exported.siteName, "Blog") + XCTAssertEqual(exported.siteURL, "https://blog.example.com") + XCTAssertEqual(exported.summary, "Summary text") + XCTAssertEqual(exported.featuredImageURL, "https://example.com/img.jpg") + XCTAssertEqual(exported.tags, ["tag1", "tag2"]) + XCTAssertEqual(exported.siteID, 999) + XCTAssertEqual(exported.postID, 888) + XCTAssertEqual(exported.isFeed, true) + XCTAssertNotNil(exported.date) + } +} + +// MARK: - Helpers + +private extension ReaderSavedPostsExporterTests { + func makeReaderPost() -> ReaderPost { + NSEntityDescription.insertNewObject( + forEntityName: "ReaderPost", + into: mainContext + ) as! ReaderPost + } + + func makeExportedPost( + url: String, + siteID: UInt?, + postID: UInt?, + isFeed: Bool = false + ) -> ReaderSavedPostsExporter.ExportedPost { + ReaderSavedPostsExporter.ExportedPost( + title: "", + url: url, + author: "", + siteName: "", + siteURL: "", + date: nil, + summary: "", + tags: nil, + featuredImageURL: nil, + siteID: siteID, + postID: postID, + isFeed: isFeed + ) + } + + func writeJSONToTempFile(_ json: [String: Any]) throws -> URL { + let data = try JSONSerialization.data(withJSONObject: json) + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".json") + try data.write(to: fileURL) + return fileURL + } + + func writeEnvelopeToTempFile(_ envelope: ReaderSavedPostsExporter.Envelope) throws -> URL { + let data = try JSONEncoder().encode(envelope) + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".json") + try data.write(to: fileURL) + return fileURL + } +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index ed90a13240e2..35ac88db8b53 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -124,6 +124,9 @@ import WordPressShared case readerCommentTextCopied case readerPostContextMenuButtonTapped case readerAddSiteToFavoritesTapped + case readerSavedPostsSettingsShown + case readerSavedPostsExported + case readerSavedPostsImported // Stats - Empty Stats nudges case statsPublicizeNudgeShown @@ -928,6 +931,12 @@ import WordPressShared return "reader_post_context_menu_button_tapped" case .readerAddSiteToFavoritesTapped: return "reader_add_site_to_favorites_tapped" + case .readerSavedPostsSettingsShown: + return "reader_saved_posts_settings_shown" + case .readerSavedPostsExported: + return "reader_saved_posts_exported" + case .readerSavedPostsImported: + return "reader_saved_posts_imported" // Stats - Empty Stats nudges case .statsPublicizeNudgeShown: diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift index fee6e65acd69..cc4e325052a7 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift @@ -320,6 +320,13 @@ class AppSettingsViewController: UITableViewController { } } + func pushSavedPostsSettings() -> ImmuTableAction { + return { [weak self] _ in + let controller = UIHostingController(rootView: ReaderSavedPostsSettingsView()) + self?.navigationController?.pushViewController(controller, animated: true) + } + } + func pushAppIconSwitcher() -> ImmuTableAction { return { [weak self] _ in let controller = AppIconViewController() @@ -575,7 +582,13 @@ private extension AppSettingsViewController { action: openApplicationSettings() ) - var rows: [ImmuTableRow] = [experimentalFeaturesRow, settingsRow] + let savedPostsRow = NavigationItemRow( + title: NSLocalizedString("reader.savedPosts.settings.row", value: "Saved Posts", comment: "Navigates to saved Reader posts export and import screen"), + icon: UIImage(systemName: "bookmark"), + action: pushSavedPostsSettings() + ) + + var rows: [ImmuTableRow] = [experimentalFeaturesRow, savedPostsRow, settingsRow] if UIApplication.shared.supportsAlternateIcons { // We don't show custom icons for Jetpack diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderSavedPostsExporter.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderSavedPostsExporter.swift new file mode 100644 index 000000000000..f7e066c74cd9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderSavedPostsExporter.swift @@ -0,0 +1,308 @@ +import Foundation +import CoreData +import WordPressData + +/// Handles exporting and importing saved Reader posts as JSON files. +struct ReaderSavedPostsExporter { + + struct Envelope: Codable { + var exportDate: String + var postCount: Int + var posts: [ExportedPost] + var appVersion: String + } + + struct ExportedPost: Codable { + var title: String + var url: String + var author: String + var siteName: String + var siteURL: String + var date: String? + var summary: String + var tags: [String]? + var featuredImageURL: String? + var siteID: UInt? + var postID: UInt? + var isFeed: Bool + } + + /// Fetches all saved Reader posts and writes them to a temporary JSON file. + /// + /// The Core Data fetch, JSON encoding, and file write all run off the main + /// thread so a large export doesn't block the UI. + /// + /// - Parameter coreDataStack: The Core Data stack. + /// - Returns: The file URL of the exported JSON, or `nil` if there are no saved posts. + func export(coreDataStack: CoreDataStackSwift) async throws -> URL? { + // Do the Core Data work on a background context and only return value + // types (no managed objects escape the closure). + let exportedPosts: [ExportedPost] = try await coreDataStack.performQuery { context in + let request = NSFetchRequest(entityName: ReaderPost.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "isSavedForLater == YES") + request.sortDescriptors = [NSSortDescriptor(key: "sortDate", ascending: false)] + + let posts = try context.fetch(request) + let dateFormatter = ISO8601DateFormatter() + + return posts.map { post in + let tags = post.tagsForDisplay() + let featuredImage = post.featuredImage + let siteID = post.siteID?.uintValue ?? 0 + let postID = post.postID?.uintValue ?? 0 + + return ExportedPost( + title: post.titleForDisplay(), + url: post.permaLink ?? "", + author: post.authorForDisplay() ?? "", + siteName: post.blogNameForDisplay() ?? "", + siteURL: post.blogURL ?? "", + date: post.dateForDisplay().map { dateFormatter.string(from: $0) }, + summary: post.contentPreviewForDisplay() ?? "", + tags: tags.isEmpty ? nil : tags, + featuredImageURL: (featuredImage?.isEmpty ?? true) ? nil : featuredImage, + siteID: siteID > 0 ? siteID : nil, + postID: postID > 0 ? postID : nil, + isFeed: post.isExternal + ) + } + } + + guard !exportedPosts.isEmpty else { return nil } + + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String + + let envelope = Envelope( + exportDate: ISO8601DateFormatter().string(from: Date()), + postCount: exportedPosts.count, + posts: exportedPosts, + appVersion: "\(appName) \(appVersion)" + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(envelope) + + let filenameDateFormatter = DateFormatter() + filenameDateFormatter.dateFormat = "yyyy-MM-dd" + let dateSuffix = filenameDateFormatter.string(from: Date()) + let fileName = "\(appName)-Saved-Posts-\(dateSuffix).json" + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + try data.write(to: fileURL) + + return fileURL + } + + struct ImportResult { + let imported: Int + let skipped: Int + let failed: Int + } + + /// Parses the JSON file and returns post entries to import. + static func parseExportFile(at fileURL: URL) throws -> [ExportedPost] { + let data = try Data(contentsOf: fileURL) + do { + let envelope = try JSONDecoder().decode(Envelope.self, from: data) + return envelope.posts + } catch { + throw ImportError.invalidFormat + } + } + + /// The maximum number of posts to fetch from the API at the same time. + /// + /// Imports run in parallel for speed, but the concurrency is bounded so a + /// large export file doesn't overwhelm the API with requests. + private static let maxConcurrentImports = 4 + + /// Imports saved posts by fetching each one from the API, then marking it as saved. + /// This ensures posts are created through the normal Core Data pipeline with all required fields. + /// + /// - Parameters: + /// - posts: Parsed post entries from a JSON export file. + /// - coreDataStack: The Core Data stack. + /// - progress: Updated as posts are processed so callers can surface import progress. + /// - Returns: A summary of how many posts were imported, skipped, or failed. + static func importPosts( + _ posts: [ExportedPost], + coreDataStack: CoreDataStackSwift, + progress: Progress + ) async -> ImportResult { + // Fetch existing saved post URLs for deduplication. + let existingURLs: Set + do { + existingURLs = try await coreDataStack.performQuery { context in + try fetchSavedPostURLs(in: context) + } + } catch { + return ImportResult(imported: 0, skipped: 0, failed: posts.count) + } + + // Filter to posts that need importing (have siteID + postID, not already saved). + var toImport: [(siteID: UInt, postID: UInt, isFeed: Bool)] = [] + var skipped = 0 + + for post in posts { + guard !post.url.isEmpty else { + skipped += 1 + continue + } + + if existingURLs.contains(post.url) { + skipped += 1 + continue + } + + guard let siteID = post.siteID, siteID > 0, + let postID = post.postID, postID > 0 + else { + Loggers.app.error("Import: skipping post with missing siteID/postID: \(post.url)") + skipped += 1 + continue + } + + toImport.append((siteID: siteID, postID: postID, isFeed: post.isFeed)) + } + + guard !toImport.isEmpty else { + return ImportResult(imported: 0, skipped: skipped, failed: 0) + } + + progress.totalUnitCount = Int64(toImport.count) + progress.completedUnitCount = 0 + + let service = ReaderPostService(coreDataStack: coreDataStack) + + var imported = 0 + var failed = 0 + + // Import posts in parallel, but bound the concurrency so we don't flood + // the API. Counter and progress mutations happen here in the parent task + // as each child task finishes, so they stay single-threaded. + await withTaskGroup(of: Bool.self) { group in + var iterator = toImport.makeIterator() + + func addNextTask() { + guard let entry = iterator.next() else { return } + group.addTask { + await importPost( + siteID: entry.siteID, + postID: entry.postID, + isFeed: entry.isFeed, + service: service, + coreDataStack: coreDataStack + ) + } + } + + for _ in 0.. Bool { + do { + let objectID = try await fetchPost( + siteID: siteID, + postID: postID, + isFeed: isFeed, + service: service + ) + try await coreDataStack.performAndSave { context in + guard let post = try context.existingObject(with: objectID) as? ReaderPost else { + throw ImportError.postUnavailable + } + if !post.isSavedForLater { + post.isSavedForLater = true + } + } + return true + } catch { + Loggers.app.error( + "Import: failed to fetch post \(postID) from site \(siteID): \(String(describing: error))" + ) + return false + } + } + + /// Wraps `ReaderPostService.fetchPost` as an async call, returning the + /// object ID of the fetched post. + private static func fetchPost( + siteID: UInt, + postID: UInt, + isFeed: Bool, + service: ReaderPostService + ) async throws -> NSManagedObjectID { + try await withCheckedThrowingContinuation { continuation in + service.fetchPost( + postID, + forSite: siteID, + isFeed: isFeed, + success: { post in + if let post { + continuation.resume(returning: post.objectID) + } else { + continuation.resume(throwing: ImportError.postUnavailable) + } + }, + failure: { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: ImportError.postUnavailable) + } + } + ) + } + } + + private static func fetchSavedPostURLs(in context: NSManagedObjectContext) throws -> Set { + let request = NSFetchRequest(entityName: ReaderPost.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "isSavedForLater == YES") + request.propertiesToFetch = ["permaLink"] + + let posts = try context.fetch(request) + return Set(posts.compactMap(\.permaLink)) + } + + enum ImportError: LocalizedError { + case invalidFormat + case postUnavailable + + var errorDescription: String? { + switch self { + case .invalidFormat: + return NSLocalizedString( + "reader.savedPosts.import.invalidFormat", + value: "The selected file is not a valid saved posts export.", + comment: "Error when the imported file doesn't match the expected JSON format" + ) + case .postUnavailable: + return nil + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift new file mode 100644 index 000000000000..38496ed342d3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift @@ -0,0 +1,280 @@ +import SwiftUI +import WordPressData +import WordPressShared +import UniformTypeIdentifiers + +struct ReaderSavedPostsSettingsView: View { + @StateObject private var viewModel: ReaderSavedPostsSettingsViewModel + + init(coreDataStack: CoreDataStackSwift = ContextManager.shared) { + _viewModel = StateObject(wrappedValue: ReaderSavedPostsSettingsViewModel(coreDataStack: coreDataStack)) + } + + var body: some View { + List { + Section { + Button { + viewModel.isShowingFilePicker = true + } label: { + Label(Strings.importButton, systemImage: "arrow.down.doc") + } + .disabled(viewModel.isBusy) + + Button { + viewModel.exportSavedPosts() + } label: { + Label(Strings.exportButton, systemImage: "arrow.up.doc") + } + .disabled(viewModel.isBusy) + + if viewModel.isImporting { + VStack(alignment: .leading, spacing: 8) { + ProgressView(value: viewModel.importProgress, total: 1.0) + Text(viewModel.importStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } else if viewModel.isExporting { + HStack(spacing: 8) { + ProgressView() + Text(Strings.exportingStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } footer: { + Text(Strings.sectionFooter) + } + } + .navigationTitle(Strings.title) + .sheet(item: $viewModel.exportedFileURL) { url in + ActivityView(activityItems: [url.value]) + } + .fileImporter( + isPresented: $viewModel.isShowingFilePicker, + allowedContentTypes: [.json], + onCompletion: viewModel.handleFileImport + ) + .alert(Strings.importCompleteTitle, isPresented: $viewModel.isShowingImportResult) { + Button(SharedStrings.Button.ok) {} + } message: { + Text(viewModel.importResultMessage) + } + .alert(Strings.errorTitle, isPresented: $viewModel.isShowingError) { + Button(SharedStrings.Button.ok) {} + } message: { + Text(viewModel.errorMessage) + } + .onAppear { + viewModel.viewAppeared() + } + } +} + +// MARK: - ViewModel + +@MainActor +final class ReaderSavedPostsSettingsViewModel: ObservableObject { + @Published var exportedFileURL: IdentifiableURL? + @Published var isShowingFilePicker = false + @Published var isShowingImportResult = false + @Published var isShowingError = false + @Published var isImporting = false + @Published var isExporting = false + @Published var importProgress: Double = 0 + @Published var importStatusText = "" + + /// Whether an import or export is currently in flight. + var isBusy: Bool { isImporting || isExporting } + + @Published private(set) var importResultMessage = "" + @Published private(set) var errorMessage = "" + + private let coreDataStack: CoreDataStackSwift + private let exporter = ReaderSavedPostsExporter() + private var progressObservation: NSKeyValueObservation? + + init(coreDataStack: CoreDataStackSwift) { + self.coreDataStack = coreDataStack + } + + func viewAppeared() { + WPAnalytics.track(.readerSavedPostsSettingsShown) + } + + func exportSavedPosts() { + guard !isBusy else { return } + isExporting = true + Task { + defer { isExporting = false } + do { + guard let fileURL = try await exporter.export(coreDataStack: coreDataStack) else { + errorMessage = Strings.exportEmpty + isShowingError = true + return + } + exportedFileURL = IdentifiableURL(value: fileURL) + WPAnalytics.track(.readerSavedPostsExported) + } catch { + errorMessage = Strings.exportError + isShowingError = true + } + } + } + + func handleFileImport(_ result: Result) { + switch result { + case .success(let url): + guard url.startAccessingSecurityScopedResource() else { + errorMessage = Strings.importError + isShowingError = true + return + } + + let posts: [ReaderSavedPostsExporter.ExportedPost] + do { + posts = try ReaderSavedPostsExporter.parseExportFile(at: url) + } catch { + url.stopAccessingSecurityScopedResource() + errorMessage = error.localizedDescription + isShowingError = true + return + } + + url.stopAccessingSecurityScopedResource() + + isImporting = true + importProgress = 0 + importStatusText = String.localizedStringWithFormat(Strings.importProgressFormat, 0, posts.count) + + let progress = Progress(totalUnitCount: Int64(posts.count)) + progressObservation = progress.observe(\.fractionCompleted) { [weak self] progress, _ in + Task { @MainActor in + self?.importProgress = progress.fractionCompleted + self?.importStatusText = String.localizedStringWithFormat( + Strings.importProgressFormat, + Int(progress.completedUnitCount), + Int(progress.totalUnitCount) + ) + } + } + + Task { + let importResult = await ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: coreDataStack, + progress: progress + ) + progressObservation = nil + isImporting = false + importResultMessage = String.localizedStringWithFormat( + Strings.importResultFormat, + importResult.imported, + importResult.skipped, + importResult.failed + ) + isShowingImportResult = true + WPAnalytics.track( + .readerSavedPostsImported, + properties: [ + "imported": importResult.imported, + "skipped": importResult.skipped, + "failed": importResult.failed + ] + ) + } + + case .failure: + errorMessage = Strings.importError + isShowingError = true + } + } +} + +// MARK: - Activity View + +private struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: - Helpers + +struct IdentifiableURL: Identifiable { + let id = UUID() + let value: URL +} + +// MARK: - Strings + +private enum Strings { + static let title = NSLocalizedString( + "reader.savedPosts.settings.title", + value: "Saved Posts", + comment: "Title for the saved Reader posts settings screen" + ) + static let exportButton = NSLocalizedString( + "reader.savedPosts.settings.export", + value: "Export Saved Posts", + comment: "Button to export saved Reader posts as a JSON file" + ) + static let importButton = NSLocalizedString( + "reader.savedPosts.settings.import", + value: "Import Saved Posts", + comment: "Button to import saved Reader posts from a JSON file" + ) + static let sectionFooter = NSLocalizedString( + "reader.savedPosts.settings.footer", + value: + "Export your saved posts as a JSON file for backup, or import a previously exported file. Duplicate posts are skipped automatically.", + comment: "Footer text explaining the saved posts export and import feature" + ) + static let exportEmpty = NSLocalizedString( + "reader.savedPosts.settings.exportEmpty", + value: "No saved posts to export.", + comment: "Message shown when user tries to export but has no saved posts" + ) + static let exportError = NSLocalizedString( + "reader.savedPosts.settings.exportError", + value: "Could not export saved posts. Please try again.", + comment: "Error message when export of saved Reader posts fails" + ) + static let exportingStatus = NSLocalizedString( + "reader.savedPosts.settings.exportingStatus", + value: "Preparing export…", + comment: "Status text shown while the saved Reader posts export file is being prepared" + ) + static let importError = NSLocalizedString( + "reader.savedPosts.settings.importError", + value: "Could not import the selected file. Please try again.", + comment: "Error message when import of saved Reader posts fails" + ) + static let importCompleteTitle = NSLocalizedString( + "reader.savedPosts.settings.importCompleteTitle", + value: "Import Complete", + comment: "Title of alert shown after importing saved posts" + ) + static let importResultFormat = NSLocalizedString( + "reader.savedPosts.settings.importResult", + value: "%1$d imported, %2$d skipped, %3$d failed.", + comment: + "Result message after importing saved posts. %1$d is imported count, %2$d is skipped count, %3$d is failed count." + ) + static let importProgressFormat = NSLocalizedString( + "reader.savedPosts.settings.importProgress", + value: "Fetching post %1$d of %2$d…", + comment: "Progress text during import. %1$d is current post number, %2$d is total." + ) + static let errorTitle = NSLocalizedString( + "reader.savedPosts.settings.errorTitle", + value: "Error", + comment: "Title of error alert in saved posts settings" + ) +} diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift index 913dfeffa8b6..670d12151fba 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift @@ -37,7 +37,9 @@ extension ReaderStreamViewController { // MARK: Private behavior private func removeShareButton() { - navigationItem.rightBarButtonItem = nil + navigationItem.rightBarButtonItems = navigationItem.rightBarButtonItems?.filter { + $0.tag != NavigationItemTag.share.rawValue + } } @objc private func shareButtonTapped(_ sender: UIBarButtonItem) { diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift index 8bd9d5f4c195..6c78ec1d8191 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift @@ -1,6 +1,7 @@ import Foundation import BuildSettingsKit import SVProgressHUD +import SwiftUI import WordPressData import WordPressFlux import WordPressShared @@ -94,6 +95,7 @@ import AutomatticTracks enum NavigationItemTag: Int { case notifications case share + case savedPostsSettings } private var siteID: NSNumber? { @@ -307,6 +309,7 @@ import AutomatticTracks NotificationCenter.default.addObserver(self, selector: #selector(postSeenToggled(_:)), name: .ReaderPostSeenToggled, object: nil) configureCloseButtonIfNeeded() + setupSavedPostsSettingsBarButtonItemIfNeeded() setupTableView() setupFooterView() setupContentHandler() @@ -398,6 +401,18 @@ import AutomatticTracks NotificationsViewController.showInPopover(from: self, sourceItem: sender) } + private func setupSavedPostsSettingsBarButtonItemIfNeeded() { + guard contentType == .saved else { return } + let action = UIAction { [weak self] _ in + let settingsVC = UIHostingController(rootView: ReaderSavedPostsSettingsView()) + self?.navigationController?.pushViewController(settingsVC, animated: true) + } + let button = UIBarButtonItem(title: nil, image: UIImage(systemName: "ellipsis.circle"), primaryAction: action) + button.tag = NavigationItemTag.savedPostsSettings.rawValue + button.accessibilityLabel = Strings.savedPostsSettingsAccessibilityLabel + addRightBarButtonItem(button) + } + // MARK: - Topic acquisition /// Fetches a site topic for the value of the `siteID` property. @@ -1703,4 +1718,5 @@ extension ReaderStreamViewController: ContentIdentifiable { private enum Strings { static let postRemoved = NSLocalizedString("reader.savedPostRemovedNotificationTitle", value: "Saved post removed", comment: "Notification title for when saved post is removed") + static let savedPostsSettingsAccessibilityLabel = NSLocalizedString("reader.savedPosts.settings.button.accessibilityLabel", value: "Saved posts settings", comment: "Accessibility label for the button that opens saved Reader posts import and export settings") }