diff --git a/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift b/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift new file mode 100644 index 000000000000..5c824bfa7324 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift @@ -0,0 +1,348 @@ +import XCTest +import WordPressData + +@testable import WordPress + +class ReaderSavedPostsExporterTests: CoreDataTestCase { + + private let exporter = ReaderSavedPostsExporter() + + // MARK: - Export + + func testExportReturnsNilWhenNoSavedPosts() throws { + let result = try exporter.export(context: mainContext) + XCTAssertNil(result) + } + + func testExportReturnsNilWhenPostsExistButNoneAreSaved() throws { + let post = makeReaderPost() + post.isSavedForLater = false + try mainContext.save() + + let result = try exporter.export(context: mainContext) + XCTAssertNil(result) + } + + func testExportCreatesJSONFileWithSavedPosts() 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 fileURL = try XCTUnwrap(exporter.export(context: mainContext)) + + 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() 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 fileURL = try XCTUnwrap(exporter.export(context: mainContext)) + 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() throws { + let post = makeReaderPost() + post.permaLink = "https://example.com/minimal" + post.isSavedForLater = true + post.sortDate = Date() + try mainContext.save() + + let fileURL = try XCTUnwrap(exporter.export(context: mainContext)) + 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() throws { + let post = makeReaderPost() + post.permaLink = "https://example.com/test" + post.isSavedForLater = true + post.sortDate = Date() + try mainContext.save() + + let fileURL = try XCTUnwrap(exporter.export(context: mainContext)) + + 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() 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 expectation = expectation(description: "import completes") + ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: { _, _ in }, + completion: { result in + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 1.0) + } + + func testImportSkipsPostsWithMissingSiteID() { + let posts = [makeExportedPost(url: "https://example.com/no-site", siteID: nil, postID: 1)] + + let expectation = expectation(description: "import completes") + ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: { _, _ in }, + completion: { result in + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 1.0) + } + + func testImportSkipsPostsWithMissingPostID() { + let posts = [makeExportedPost(url: "https://example.com/no-post-id", siteID: 100, postID: nil)] + + let expectation = expectation(description: "import completes") + ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: { _, _ in }, + completion: { result in + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 1.0) + } + + func testImportSkipsPostsWithEmptyURL() { + let posts = [makeExportedPost(url: "", siteID: 100, postID: 1)] + + let expectation = expectation(description: "import completes") + ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: contextManager, + progress: { _, _ in }, + completion: { result in + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.failed, 0) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 1.0) + } + + func testImportReturnsEmptyResultForEmptyPostsList() { + let expectation = expectation(description: "import completes") + ReaderSavedPostsExporter.importPosts( + [], + coreDataStack: contextManager, + progress: { _, _ in }, + completion: { result in + XCTAssertEqual(result.imported, 0) + XCTAssertEqual(result.skipped, 0) + XCTAssertEqual(result.failed, 0) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Round-trip (export -> parse) + + func testExportThenParsePreservesAllFields() 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 fileURL = try XCTUnwrap(exporter.export(context: mainContext)) + 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 cb2b0661af66..f79e30d704b4 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 d4626676c0b2..9370afdeb838 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() @@ -576,7 +583,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..13dd201b8d93 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderSavedPostsExporter.swift @@ -0,0 +1,248 @@ +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. + /// - Parameter context: The managed object context to fetch from. + /// - Returns: The file URL of the exported JSON, or `nil` if there are no saved posts. + func export(context: NSManagedObjectContext) throws -> URL? { + 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) + guard !posts.isEmpty else { return nil } + + let dateFormatter = ISO8601DateFormatter() + + let exportedPosts: [ExportedPost] = 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 + ) + } + + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String + + let envelope = Envelope( + exportDate: dateFormatter.string(from: Date()), + postCount: posts.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 + } + } + + /// 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: Called after each post is processed with (completed, total). + /// - completion: Called when all posts have been processed. + static func importPosts( + _ posts: [ExportedPost], + coreDataStack: CoreDataStack, + progress: @escaping (Int, Int) -> Void, + completion: @escaping (ImportResult) -> Void + ) { + let context = coreDataStack.mainContext + + // Fetch existing saved post URLs for deduplication + let existingURLs: Set + do { + existingURLs = try fetchSavedPostURLs(in: context) + } catch { + completion(ImportResult(imported: 0, skipped: 0, failed: posts.count)) + return + } + + // 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 { + DDLogError("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 { + completion(ImportResult(imported: 0, skipped: skipped, failed: 0)) + return + } + + let total = toImport.count + let readerPostService = ReaderPostService(coreDataStack: coreDataStack) + + // Process posts sequentially to avoid overwhelming the API with parallel + // requests. All counter mutations and recursion happen on the main queue + // so they remain single-threaded regardless of which queue the underlying + // success/failure callback fires on. + var imported = 0 + var failed = 0 + var pendingSave = false + + func finish() { + if pendingSave { + coreDataStack.save(context) + } + completion(ImportResult(imported: imported, skipped: skipped, failed: failed)) + } + + func fetchNext(index: Int) { + guard index < toImport.count else { + finish() + return + } + + let entry = toImport[index] + readerPostService.fetchPost( + entry.postID, + forSite: entry.siteID, + isFeed: entry.isFeed, + success: { post in + DispatchQueue.main.async { + if let post { + if !post.isSavedForLater { + post.isSavedForLater = true + pendingSave = true + } + imported += 1 + } else { + DDLogError( + "Import: fetchPost returned nil for post \(entry.postID) from site \(entry.siteID)" + ) + failed += 1 + } + progress(index + 1, total) + fetchNext(index: index + 1) + } + }, + failure: { error in + DispatchQueue.main.async { + DDLogError( + "Import: failed to fetch post \(entry.postID) from site \(entry.siteID): \(String(describing: error))" + ) + failed += 1 + progress(index + 1, total) + fetchNext(index: index + 1) + } + } + ) + } + + fetchNext(index: 0) + } + + 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 + + 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" + ) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift new file mode 100644 index 000000000000..db65b76b1835 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSavedPostsSettingsView.swift @@ -0,0 +1,255 @@ +import SwiftUI +import WordPressData +import WordPressShared +import UniformTypeIdentifiers + +struct ReaderSavedPostsSettingsView: View { + @StateObject private var viewModel: ReaderSavedPostsSettingsViewModel + + init(coreDataStack: CoreDataStack = 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.isImporting) + + Button { + viewModel.exportSavedPosts() + } label: { + Label(Strings.exportButton, systemImage: "arrow.up.doc") + } + .disabled(viewModel.isImporting) + + 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) + } + } 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 importProgress: Double = 0 + @Published var importStatusText = "" + + @Published private(set) var importResultMessage = "" + @Published private(set) var errorMessage = "" + + private let coreDataStack: CoreDataStack + private let exporter = ReaderSavedPostsExporter() + + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + } + + func viewAppeared() { + WPAnalytics.track(.readerSavedPostsSettingsShown) + } + + func exportSavedPosts() { + do { + guard let fileURL = try exporter.export(context: coreDataStack.mainContext) 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) + + ReaderSavedPostsExporter.importPosts( + posts, + coreDataStack: coreDataStack, + progress: { [weak self] completed, total in + DispatchQueue.main.async { + self?.importProgress = Double(completed) / Double(total) + self?.importStatusText = String.localizedStringWithFormat( + Strings.importProgressFormat, + completed, + total + ) + } + }, + completion: { [weak self] importResult in + DispatchQueue.main.async { + self?.isImporting = false + self?.importResultMessage = String.localizedStringWithFormat( + Strings.importResultFormat, + importResult.imported, + importResult.skipped, + importResult.failed + ) + self?.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 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 925d4f7044d1..5bb108d26589 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 7897e74523ce..7655f68d90ad 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. @@ -1705,4 +1720,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") }