From 6d6a6c6d46da0ccdac39c6dccc166c9e061fd123 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 27 Sep 2026 08:50:23 -0500 Subject: [PATCH] Add favorites backups and streamline board and general lookup --- README.md | 4 + swiftchan.xcodeproj/project.pbxproj | 16 +++ swiftchan/Models/FavoritesBackup.swift | 104 +++++++++++++++++ swiftchan/Services/DeepLinker.swift | 6 + swiftchan/Services/FavoritesBackupStore.swift | 43 +++++++ .../RecurringFavoriteViewModel.swift | 41 ++----- swiftchan/Views/Boards/OpenLinkSheet.swift | 6 +- .../Views/Favorites/FavoritesBackupView.swift | 110 ++++++++++++++++++ swiftchan/Views/SettingsView.swift | 6 + swiftchanTests/DeepLinkerTests.swift | 12 ++ swiftchanTests/FavoritesBackupTests.swift | 63 ++++++++++ swiftchanTests/RecurringFavoriteTests.swift | 56 +++++++++ swiftchanUITests/BrowsingUITests.swift | 20 ++++ 13 files changed, 455 insertions(+), 32 deletions(-) create mode 100644 swiftchan/Models/FavoritesBackup.swift create mode 100644 swiftchan/Services/FavoritesBackupStore.swift create mode 100644 swiftchan/Views/Favorites/FavoritesBackupView.swift create mode 100644 swiftchanTests/FavoritesBackupTests.swift diff --git a/README.md b/README.md index dea8fd5..30aa4fd 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ If you want to test it with me on testflight or think we can get it on the app s ## Usage +Use **Settings → Favorites Backup** to export saved thread links and named generals as JSON. Importing a backup adds missing entries while keeping existing names. Backups do not include media files, settings, or reading progress. + +Use **Open Link** on the Boards tab to jump to a board by entering its shortcut, such as `/biz/`, or paste a full board or thread URL. + To share a specific reply, open its **… → Share Post** action. The link includes the post number and opens at that reply in Swiftchan or on the website. Use a post's **… → Hide** action to hide it, then tap **Undo** if needed. **Settings → Hidden Posts & Threads** lists hidden items by board and post number, with search, individual restore, and **Restore All**. Previously hidden items appear there too. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 95b496a..591e117 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000004EEEE000100000001 /* FavoritesBackupTests.swift */; }; + BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000003EEEE000100000001 /* FavoritesBackupView.swift */; }; + BF000002EEEE000100000002 /* FavoritesBackupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000002EEEE000100000001 /* FavoritesBackupStore.swift */; }; + BF000001EEEE000100000002 /* FavoritesBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000001EEEE000100000001 /* FavoritesBackup.swift */; }; EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */; }; EE000002EEEE000100000002 /* HiddenPostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE000002EEEE000100000001 /* HiddenPostsView.swift */; }; EE000001EEEE000100000002 /* HiddenPostStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE000001EEEE000100000001 /* HiddenPostStore.swift */; }; @@ -147,6 +151,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BF000004EEEE000100000001 /* FavoritesBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupTests.swift; sourceTree = ""; }; + BF000003EEEE000100000001 /* FavoritesBackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupView.swift; sourceTree = ""; }; + BF000002EEEE000100000001 /* FavoritesBackupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupStore.swift; sourceTree = ""; }; + BF000001EEEE000100000001 /* FavoritesBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackup.swift; sourceTree = ""; }; EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostStoreTests.swift; sourceTree = ""; }; EE000002EEEE000100000001 /* HiddenPostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostsView.swift; sourceTree = ""; }; EE000001EEEE000100000001 /* HiddenPostStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostStore.swift; sourceTree = ""; }; @@ -293,6 +301,7 @@ D01E215C9A3306E1A631FA2C /* FavoriteThreadRow.swift */, E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */, 9CBC04E821A80DD362D9DDF2 /* RecurringMatchSheet.swift */, + BF000003EEEE000100000001 /* FavoritesBackupView.swift */, 9044AE15507088A1373984CA /* AddRecurringFavoriteSheet.swift */, ); path = Favorites; @@ -329,6 +338,7 @@ 9510536725660071002E4051 /* Models */ = { isa = PBXGroup; children = ( + BF000001EEEE000100000001 /* FavoritesBackup.swift */, EE000001EEEE000100000001 /* HiddenPostStore.swift */, DD000002DDDD000100000001 /* GeneralSuggestion.swift */, DD000001DDDD000100000001 /* ThreadReadingProgress.swift */, @@ -396,6 +406,7 @@ 951817A1254CFB620032616E /* swiftchanTests */ = { isa = PBXGroup; children = ( + BF000004EEEE000100000001 /* FavoritesBackupTests.swift */, EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */, DD000004DDDD000100000001 /* ReadingProgressTests.swift */, CC000006CCCC000100000006 /* RecurringFavoriteTests.swift */, @@ -453,6 +464,7 @@ 9580614A255263DB0003FFF3 /* Services */ = { isa = PBXGroup; children = ( + BF000002EEEE000100000001 /* FavoritesBackupStore.swift */, DD000003DDDD000100000001 /* ThreadUITestFixture.swift */, 9537082427F801FA00A4DF8F /* MultiActionItem.swift */, 95F6245F255CE64400E9147B /* URLExamples.swift */, @@ -783,10 +795,12 @@ AA000001AAAA000100000001 /* VideoContainerView.swift in Sources */, AA000002AAAA000100000002 /* VideoPlayerControlsView.swift in Sources */, 9559F79328443E7D00F5803F /* ThreadAutoRefresher.swift in Sources */, + BF000001EEEE000100000002 /* FavoritesBackup.swift in Sources */, EE000001EEEE000100000002 /* HiddenPostStore.swift in Sources */, EE000002EEEE000100000002 /* HiddenPostsView.swift in Sources */, DD000001DDDD000100000002 /* ThreadReadingProgress.swift in Sources */, DD000002DDDD000100000002 /* GeneralSuggestion.swift in Sources */, + BF000002EEEE000100000002 /* FavoritesBackupStore.swift in Sources */, DD000003DDDD000100000002 /* ThreadUITestFixture.swift in Sources */, CC000003CCCC000100000003 /* OpenLinkSheet.swift in Sources */, 95181793254CFB600032616E /* BoardsView.swift in Sources */, @@ -853,6 +867,7 @@ 1A5F76AD0CB8E1E22917C335 /* RecurringFavoriteViewModel.swift in Sources */, 6B83449B7D7B012156947080 /* RecurringFavoriteRow.swift in Sources */, 4B1F4B029F952735A2843BFF /* RecurringMatchSheet.swift in Sources */, + BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */, 2C999DFDE990B515FFB0B779 /* AddRecurringFavoriteSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -861,6 +876,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */, EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */, DD000004DDDD000100000002 /* ReadingProgressTests.swift in Sources */, CC000005CCCC000100000005 /* RecurringFavoriteTests.swift in Sources */, diff --git a/swiftchan/Models/FavoritesBackup.swift b/swiftchan/Models/FavoritesBackup.swift new file mode 100644 index 0000000..03d706f --- /dev/null +++ b/swiftchan/Models/FavoritesBackup.swift @@ -0,0 +1,104 @@ +import Foundation + +struct FavoritesBackup: Codable, Equatable { + static let maximumBytes = 5 * 1_024 * 1_024 + + struct Thread: Codable, Equatable { + var board: String + let number: Int + let title: String + let createdAt: Date + let savedAt: Date + var key: String { "\(board)/\(number)" } + } + + struct General: Codable, Equatable { + var board: String + var tag: String + var name: String? + let createdAt: Date + var key: String { "\(board)/\(tag)" } + } + + struct ImportPlan { + let threads: [Thread] + let generals: [General] + let skipped: Int + } + + let format: String + let version: Int + let exportedAt: Date + var threads: [Thread] + var generals: [General] + + init(threads: [Thread], generals: [General], exportedAt: Date = Date()) { + format = "swiftchan.favorites" + version = 1 + self.exportedAt = exportedAt + self.threads = threads + self.generals = generals + } + + func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(validated()) + guard data.count <= Self.maximumBytes else { throw BackupError.tooLarge } + return data + } + + static func decode(_ data: Data) throws -> Self { + guard data.count <= maximumBytes else { throw BackupError.tooLarge } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(Self.self, from: data).validated() + } + + func validated() throws -> Self { + guard format == "swiftchan.favorites" else { throw BackupError.invalidFile } + guard version == 1 else { throw BackupError.unsupportedVersion } + var result = self + for index in result.threads.indices { + guard let board = Deeplinker.normalizedBoard(result.threads[index].board), result.threads[index].number > 0 else { + throw BackupError.invalidFile + } + result.threads[index].board = board + } + for index in result.generals.indices { + guard let board = Deeplinker.normalizedBoard(result.generals[index].board) else { throw BackupError.invalidFile } + let tag = result.generals[index].tag.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")).trimmingCharacters(in: .whitespacesAndNewlines) + guard !tag.isEmpty, !tag.contains("/"), tag.rangeOfCharacter(from: .newlines) == nil else { throw BackupError.invalidFile } + result.generals[index].board = board + result.generals[index].tag = "/\(tag.lowercased())/" + let name = result.generals[index].name?.trimmingCharacters(in: .whitespacesAndNewlines) + result.generals[index].name = name?.isEmpty == false ? name : nil + } + return result + } + + /// Existing favorites win; repeated records in a file are imported only once. + func importPlan(threadKeys: Set, generalKeys: Set) throws -> ImportPlan { + let backup = try validated() + var threadsSeen = threadKeys + var generalsSeen = generalKeys + let threads = backup.threads.filter { threadsSeen.insert($0.key).inserted } + let generals = backup.generals.filter { generalsSeen.insert($0.key).inserted } + return ImportPlan(threads: threads, generals: generals, + skipped: backup.threads.count + backup.generals.count - threads.count - generals.count) + } + + enum BackupError: LocalizedError { + case invalidFile, unsupportedVersion, tooLarge + + var errorDescription: String? { + switch self { + case .invalidFile: return "This file contains invalid favorites. Nothing was imported." + case .unsupportedVersion: return "This backup needs a newer version of Swiftchan." + case .tooLarge: return "Choose a favorites backup of 5 MB or smaller." + } + } + } +} diff --git a/swiftchan/Services/DeepLinker.swift b/swiftchan/Services/DeepLinker.swift index f4172ce..9aeb917 100644 --- a/swiftchan/Services/DeepLinker.swift +++ b/swiftchan/Services/DeepLinker.swift @@ -10,6 +10,12 @@ enum Deeplinker { static func parse(_ text: String) -> Deeplink? { var text = text.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("/"), text.hasSuffix("/"), text.count > 2 { + let board = String(text.dropFirst().dropLast()) + if !board.contains("/"), let name = normalizedBoard(board) { + return .board(name: name) + } + } if text.lowercased().hasPrefix("boards.4chan.org/") || text.lowercased().hasPrefix("boards.4channel.org/") { text = "https://" + text } diff --git a/swiftchan/Services/FavoritesBackupStore.swift b/swiftchan/Services/FavoritesBackupStore.swift new file mode 100644 index 0000000..57ffb65 --- /dev/null +++ b/swiftchan/Services/FavoritesBackupStore.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftData + +@MainActor +enum FavoritesBackupStore { + static func export(from context: ModelContext) throws -> FavoritesBackup { + let threads = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.savedAt)])) + let generals = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)])) + return FavoritesBackup(threads: threads.map { + .init(board: $0.boardName, number: $0.threadId, title: $0.title, createdAt: $0.createdTime, savedAt: $0.savedAt) + }, generals: generals.map { + .init(board: $0.boardName, tag: $0.searchPattern, name: $0.displayName, createdAt: $0.createdAt) + }) + } + + static func restore(_ backup: FavoritesBackup, in context: ModelContext) throws -> FavoritesBackup.ImportPlan { + let existing = try export(from: context).validated() + let plan = try backup.importPlan(threadKeys: Set(existing.threads.map(\.key)), generalKeys: Set(existing.generals.map(\.key))) + var insertedThreads: [FavoriteThread] = [] + var insertedGenerals: [RecurringFavorite] = [] + for record in plan.threads { + let thread = FavoriteThread(threadId: record.number, boardName: record.board, title: record.title, + createdTime: record.createdAt, savedAt: record.savedAt) + context.insert(thread) + insertedThreads.append(thread) + } + for record in plan.generals { + let general = RecurringFavorite(searchPattern: record.tag, boardName: record.board, displayName: record.name) + general.createdAt = record.createdAt + context.insert(general) + insertedGenerals.append(general) + } + do { + if !insertedThreads.isEmpty || !insertedGenerals.isEmpty { try context.save() } + return plan + } catch { + // Remove only this import's inserts; preserve unrelated edits in the shared context. + insertedThreads.forEach { context.delete($0) } + insertedGenerals.forEach { context.delete($0) } + throw error + } + } +} diff --git a/swiftchan/ViewModels/RecurringFavoriteViewModel.swift b/swiftchan/ViewModels/RecurringFavoriteViewModel.swift index 4334498..a50f024 100644 --- a/swiftchan/ViewModels/RecurringFavoriteViewModel.swift +++ b/swiftchan/ViewModels/RecurringFavoriteViewModel.swift @@ -12,10 +12,13 @@ import FourChan class RecurringFavoriteViewModel { typealias CatalogLoader = (String) async throws -> Catalog @ObservationIgnored private let fetchCatalog: CatalogLoader + @ObservationIgnored private let parseComment: (String) -> AttributedString @ObservationIgnored private var activeRequest: UUID? - init(fetchCatalog: @escaping CatalogLoader = { try await FourChanAsyncService.shared.getCatalog(boardName: $0) }) { + init(fetchCatalog: @escaping CatalogLoader = { try await FourChanAsyncService.shared.getCatalog(boardName: $0) }, + parseComment: @escaping (String) -> AttributedString = { CommentParser(comment: $0).getComment() }) { self.fetchCatalog = fetchCatalog + self.parseComment = parseComment } enum MatchState: Equatable { @@ -54,24 +57,16 @@ class RecurringFavoriteViewModel { guard activeRequest == request else { return } try Task.checkCancellation() - var allPosts: [SwiftchanPost] = [] - var index = 0 - - for page in catalog { - for thread in page.threads { - let comment: AttributedString - if let com = thread.com { - comment = CommentParser(comment: com).getComment() - } else { - comment = AttributedString() - } - allPosts.append(SwiftchanPost(post: thread, boardName: favorite.boardName, comment: comment, index: index)) - index += 1 - } + let pattern = favorite.searchPattern.lowercased() + // Filter cheap title strings before parsing rich comments for the matching threads. + let matchingThreads = catalog.flatMap(\.threads).enumerated().filter { + ($0.element.sub?.clean.lowercased() ?? "").contains(pattern) + }.sorted { $0.element.no > $1.element.no } + let matches = matchingThreads.map { index, thread in + SwiftchanPost(post: thread, boardName: favorite.boardName, + comment: thread.com.map(parseComment) ?? AttributedString(), index: index) } - let matches = filterPosts(allPosts, pattern: favorite.searchPattern) - favorite.lastMatchedAt = Date() favorite.lastMatchCount = matches.count @@ -96,18 +91,6 @@ class RecurringFavoriteViewModel { } } - private func filterPosts(_ posts: [SwiftchanPost], pattern: String) -> [SwiftchanPost] { - let searchPattern = pattern.lowercased() - - // Only match against the OP title (subject) - let filtered = posts.filter { swiftChanPost in - let subject = swiftChanPost.post.sub?.clean.lowercased() ?? "" - return subject.contains(searchPattern) - } - - return filtered.sorted { $0.post.no > $1.post.no } - } - func reset() { activeRequest = nil state = .idle diff --git a/swiftchan/Views/Boards/OpenLinkSheet.swift b/swiftchan/Views/Boards/OpenLinkSheet.swift index 89d3e3c..478e9de 100644 --- a/swiftchan/Views/Boards/OpenLinkSheet.swift +++ b/swiftchan/Views/Boards/OpenLinkSheet.swift @@ -23,12 +23,12 @@ struct OpenLinkSheet: View { if let value = values.first { text = value } } } header: { - Text("Board or thread URL") + Text("Board shortcut or thread URL") } footer: { - Text("Paste a 4chan or 4channel link to open it here.") + Text("Enter a board such as /biz/, or paste a 4chan or 4channel link.") } if showError { - Text("Enter a valid board or thread link from boards.4chan.org or boards.4channel.org.") + Text("Enter a board such as /biz/, or a valid link from boards.4chan.org or boards.4channel.org.") .foregroundStyle(.red) .accessibilityIdentifier("Open Link Error") } diff --git a/swiftchan/Views/Favorites/FavoritesBackupView.swift b/swiftchan/Views/Favorites/FavoritesBackupView.swift new file mode 100644 index 0000000..df94067 --- /dev/null +++ b/swiftchan/Views/Favorites/FavoritesBackupView.swift @@ -0,0 +1,110 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers + +struct FavoritesBackupView: View { + @Environment(\.modelContext) private var context + @Query private var threads: [FavoriteThread] + @Query private var generals: [RecurringFavorite] + @State private var document = FavoritesBackupDocument() + @State private var exporting = false + @State private var importing = false + @State private var confirmingImport = false + @State private var pendingBackup: FavoritesBackup? + @State private var message = "" + @State private var showingMessage = false + + var body: some View { + Form { + Section { + LabeledContent("Saved threads", value: "\(threads.count)") + LabeledContent("Named generals", value: "\(generals.count)") + } footer: { + Text("Back up saved thread links and named generals. Media files, settings, and reading progress are not included.") + } + Section { + Button("Export Favorites", systemImage: "square.and.arrow.up", action: exportFavorites) + .disabled(threads.isEmpty && generals.isEmpty) + .accessibilityIdentifier("Export Favorites") + Button("Import Favorites", systemImage: "square.and.arrow.down") { importing = true } + .accessibilityIdentifier("Import Favorites") + } footer: { + Text("Save a backup in Files or share it with another device. Importing adds missing favorites and keeps your existing names.") + } + } + .navigationTitle("Favorites Backup") + .navigationBarTitleDisplayMode(.inline) + .fileExporter(isPresented: $exporting, document: document, contentType: .json, defaultFilename: "Swiftchan Favorites") { result in + if case .failure(let error) = result { report(error.localizedDescription) } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in + do { + let url = try result.get() + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: FavoritesBackup.maximumBytes + 1) ?? Data() + pendingBackup = try FavoritesBackup.decode(data) + confirmingImport = true + } catch { + pendingBackup = nil + report(error.localizedDescription) + } + } + .confirmationDialog("Import favorites?", isPresented: $confirmingImport, titleVisibility: .visible) { + Button("Import", action: importFavorites) + Button("Cancel", role: .cancel) { pendingBackup = nil } + } message: { + if let backup = pendingBackup { + Text("This backup contains \(backup.threads.count) threads and \(backup.generals.count) named generals. Existing favorites will be kept.") + } + } + .alert("Favorites Backup", isPresented: $showingMessage) { + Button("OK", role: .cancel) { } + } message: { + Text(message) + } + } + + private func exportFavorites() { + do { + document = FavoritesBackupDocument(data: try FavoritesBackupStore.export(from: context).encoded()) + exporting = true + } catch { + report(error.localizedDescription) + } + } + + private func importFavorites() { + guard let backup = pendingBackup else { return } + pendingBackup = nil + do { + let plan = try FavoritesBackupStore.restore(backup, in: context) + report("Imported \(plan.threads.count) threads and \(plan.generals.count) named generals. Skipped \(plan.skipped) duplicates.") + } catch { + report(error.localizedDescription) + } + } + + private func report(_ text: String) { + message = text + showingMessage = true + } +} + +struct FavoritesBackupDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } + let data: Data + + init(data: Data = Data()) { self.data = data } + + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { throw FavoritesBackup.BackupError.invalidFile } + self.data = data + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/swiftchan/Views/SettingsView.swift b/swiftchan/Views/SettingsView.swift index 3f93762..3cd7374 100644 --- a/swiftchan/Views/SettingsView.swift +++ b/swiftchan/Views/SettingsView.swift @@ -34,6 +34,12 @@ struct SettingsView: View { Label("Hidden Posts & Threads", systemImage: "eye.slash") } .accessibilityIdentifier("Manage Hidden Posts") + NavigationLink { + FavoritesBackupView() + } label: { + Label("Favorites Backup", systemImage: "square.and.arrow.up.on.square") + } + .accessibilityIdentifier("Manage Favorites Backup") } mediaSection threadSection diff --git a/swiftchanTests/DeepLinkerTests.swift b/swiftchanTests/DeepLinkerTests.swift index d0d0e06..d1bbad0 100644 --- a/swiftchanTests/DeepLinkerTests.swift +++ b/swiftchanTests/DeepLinkerTests.swift @@ -57,6 +57,18 @@ final class DeepLinkerTests: XCTestCase { } extension DeepLinkerTests { + func testBoardShortcutsAreNormalized() { + XCTAssertEqual(Deeplinker.parse("/biz/"), .board(name: "biz")) + XCTAssertEqual(Deeplinker.parse(" /BIZ/\n"), .board(name: "biz")) + XCTAssertEqual(Deeplinker.parse("/3/"), .board(name: "3")) + } + + func testMalformedBoardShortcutsAreRejected() { + for text in ["/", "//", "///biz///", "//example.com/", "/biz/thread/123/", "/bad board/", "biz"] { + XCTAssertNil(Deeplinker.parse(text), text) + } + } + func testPastedLinkTrimsWhitespaceAndAcceptsMissingScheme() { XCTAssertEqual(Deeplinker.parse(" boards.4chan.org/biz/\n"), .board(name: "biz")) XCTAssertNil(Deeplinker.parse("")) diff --git a/swiftchanTests/FavoritesBackupTests.swift b/swiftchanTests/FavoritesBackupTests.swift new file mode 100644 index 0000000..c9e3c10 --- /dev/null +++ b/swiftchanTests/FavoritesBackupTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import swiftchan + +final class FavoritesBackupTests: XCTestCase { + private let date = Date(timeIntervalSince1970: 1_700_000_000) + + func testRoundTripPreservesLinksNamesAndDates() throws { + let backup = example() + XCTAssertEqual(try FavoritesBackup.decode(backup.encoded()), backup) + } + + func testImportNormalizesBeforeDeduplicatingAndKeepsExistingFavorites() throws { + var backup = example() + backup.threads.append(.init(board: "/BIZ/", number: 100, title: "Duplicate", createdAt: date, savedAt: date)) + backup.threads.append(.init(board: "g", number: 100, title: "Different board", createdAt: date, savedAt: date)) + backup.generals.append(.init(board: "/BIZ/", tag: "PMG", name: "Duplicate", createdAt: date)) + let plan = try backup.importPlan(threadKeys: ["biz/100"], generalKeys: ["biz//pmg/"]) + XCTAssertEqual(plan.threads.map(\.key), ["g/100"]) + XCTAssertTrue(plan.generals.isEmpty) + XCTAssertEqual(plan.skipped, 4) + let emptyPlan = try backup.importPlan(threadKeys: [], generalKeys: []) + XCTAssertEqual(emptyPlan.threads.count, 2) + XCTAssertEqual(emptyPlan.generals.count, 1) + XCTAssertEqual(emptyPlan.generals.first?.name, "Precious Metals General") + } + + func testInvalidRecordRejectsTheEntireBackup() throws { + var backup = example() + backup.threads.append(.init(board: "bad/board", number: 200, title: "Invalid", createdAt: date, savedAt: date)) + XCTAssertThrowsError(try backup.importPlan(threadKeys: [], generalKeys: [])) + backup = example() + backup.generals[0].tag = "///" + XCTAssertThrowsError(try backup.encoded()) + backup = example() + backup.generals[0].tag = "pmg\rbroken" + XCTAssertThrowsError(try backup.encoded()) + } + + func testRejectsUnknownFormatsVersionsAndOversizedFiles() throws { + let data = try example().encoded() + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + object["version"] = 2 + XCTAssertThrowsError(try FavoritesBackup.decode(JSONSerialization.data(withJSONObject: object))) + object["version"] = 1 + object["format"] = "another.app" + XCTAssertThrowsError(try FavoritesBackup.decode(JSONSerialization.data(withJSONObject: object))) + XCTAssertThrowsError(try FavoritesBackup.decode(Data(repeating: 0, count: FavoritesBackup.maximumBytes + 1))) + XCTAssertThrowsError(try FavoritesBackup.decode(Data("not JSON".utf8))) + } + + func testEmptyBackupIsAValidNoOp() throws { + let backup = try FavoritesBackup.decode(FavoritesBackup(threads: [], generals: []).encoded()) + let plan = try backup.importPlan(threadKeys: [], generalKeys: []) + XCTAssertTrue(plan.threads.isEmpty) + XCTAssertTrue(plan.generals.isEmpty) + XCTAssertEqual(plan.skipped, 0) + } + + private func example() -> FavoritesBackup { + FavoritesBackup(threads: [.init(board: "biz", number: 100, title: "/pmg/", createdAt: date, savedAt: date)], + generals: [.init(board: "biz", tag: "/pmg/", name: "Precious Metals General", createdAt: date)], exportedAt: date) + } +} diff --git a/swiftchanTests/RecurringFavoriteTests.swift b/swiftchanTests/RecurringFavoriteTests.swift index ab80da1..5919a12 100644 --- a/swiftchanTests/RecurringFavoriteTests.swift +++ b/swiftchanTests/RecurringFavoriteTests.swift @@ -66,6 +66,31 @@ final class RecurringFavoriteTests: XCTestCase { XCTAssertNotNil(favorite.lastMatchedAt) } + func testOnlyMatchingCommentsAreParsedAndOriginalCatalogIndicesArePreserved() async throws { + let response = try catalog(#"[{"no":1,"sub":"Other","com":"skip"},{"no":2,"sub":"/pmg/ old","com":"old"},{"no":3,"sub":"Other","com":"/pmg/ in body only"},{"no":4,"sub":"/PMG/ new","com":"new"}]"#) + var parsed: [String] = [] + let model = RecurringFavoriteViewModel(fetchCatalog: { _ in response }, parseComment: { + parsed.append($0) + return AttributedString($0) + }) + await model.findMatches(for: RecurringFavorite(searchPattern: "/pmg/", boardName: "biz")) + guard case .multipleMatches(let matches) = model.state else { return XCTFail("Expected matching generals") } + XCTAssertEqual(parsed.sorted(), ["new", "old"]) + XCTAssertEqual(matches.map(\.id), [4, 2]) + XCTAssertEqual(matches.map(\.index), [3, 1]) + XCTAssertEqual(matches.map { String($0.comment.characters) }, ["new", "old"]) + } + + func testNoMatchingTitlesDoesNotParseAnyComments() async throws { + let response = try catalog(#"[{"no":1,"com":"/pmg/"},{"no":2,"sub":"Other","com":"skip"}]"#) + let model = RecurringFavoriteViewModel(fetchCatalog: { _ in response }, parseComment: { _ in + XCTFail("Unmatched catalog comments should not be parsed") + return AttributedString() + }) + await model.findMatches(for: RecurringFavorite(searchPattern: "/pmg/", boardName: "biz")) + XCTAssertEqual(model.state, .noMatches) + } + func testCancelledLookupDoesNotPublishResultsOrChangeSavedMetadata() async throws { let response = try catalog(#"[{"no":1,"sub":"/pmg/"}]"#) var continuation: CheckedContinuation? @@ -129,4 +154,35 @@ final class RecurringFavoriteTests: XCTestCase { private func catalog(_ threads: String) throws -> Catalog { try JSONDecoder().decode(Catalog.self, from: Data("[{\"page\":1,\"threads\":\(threads)}]".utf8)) } + + func testBackupRestoreAddsMissingFavoritesAndKeepsExistingNames() throws { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container) + _ = try XCTUnwrap(RecurringFavoriteDraft(boardName: "biz", searchPattern: "pmg", displayName: "My Metals")).save(in: context) + let date = Date(timeIntervalSince1970: 1_700_000_000) + let backup = FavoritesBackup(threads: [.init(board: "biz", number: 100, title: "Saved thread", createdAt: date, savedAt: date)], + generals: [.init(board: "biz", tag: "/pmg/", name: "Imported name", createdAt: date), + .init(board: "g", tag: "/dpt/", name: "Daily Programming", createdAt: date)]) + let first = try FavoritesBackupStore.restore(backup, in: context) + XCTAssertEqual(first.threads.count, 1) + XCTAssertEqual(first.generals.count, 1) + XCTAssertEqual(first.skipped, 1) + let second = try FavoritesBackupStore.restore(backup, in: context) + XCTAssertEqual(second.skipped, 3) + let saved = try FavoritesBackupStore.export(from: context) + XCTAssertEqual(saved.threads.count, 1) + XCTAssertEqual(saved.threads.first?.savedAt, date) + XCTAssertEqual(saved.generals.count, 2) + XCTAssertEqual(saved.generals.first { $0.board == "biz" }?.name, "My Metals") + } + + func testInvalidBackupDoesNotPartiallyInsertFavorites() throws { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container) + let date = Date() + let backup = FavoritesBackup(threads: [.init(board: "biz", number: 100, title: "Valid", createdAt: date, savedAt: date), + .init(board: "biz", number: -1, title: "Invalid", createdAt: date, savedAt: date)], generals: []) + XCTAssertThrowsError(try FavoritesBackupStore.restore(backup, in: context)) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + } } diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index ca8de20..e3cc45d 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,26 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testFavoritesBackupShowsSavedGeneralAndOpensExport() { + let app = launchApp() + app.buttons["Settings"].tap() + app.buttons["Manage Favorites Backup"].tap() + XCTAssertTrue(app.buttons["Export Favorites"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Export Favorites"].isEnabled) + XCTAssertTrue(app.buttons["Import Favorites"].isEnabled) + app.buttons["Favorites"].tap() + app.buttons["Follow General Button"].tap() + fillGeneral(in: app, name: "Precious Metals General") + app.buttons["Save General"].tap() + app.buttons["Settings"].tap() + XCTAssertTrue(app.buttons["Export Favorites"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Export Favorites"].isEnabled) + attachScreenshot("Favorites Backup", app: app) + app.buttons["Export Favorites"].tap() + XCTAssertTrue(app.buttons["Save"].waitForExistence(timeout: 10)) + attachScreenshot("Export Favorites In Files", app: app) + } + func testFollowNamedGeneralDirectlyFromFavoritesAndEditIt() { let app = launchApp() app.buttons["Favorites"].tap()