Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 EXAMPLES -->
## 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.
Expand Down
16 changes: 16 additions & 0 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -147,6 +151,10 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
BF000004EEEE000100000001 /* FavoritesBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupTests.swift; sourceTree = "<group>"; };
BF000003EEEE000100000001 /* FavoritesBackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupView.swift; sourceTree = "<group>"; };
BF000002EEEE000100000001 /* FavoritesBackupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupStore.swift; sourceTree = "<group>"; };
BF000001EEEE000100000001 /* FavoritesBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackup.swift; sourceTree = "<group>"; };
EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostStoreTests.swift; sourceTree = "<group>"; };
EE000002EEEE000100000001 /* HiddenPostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostsView.swift; sourceTree = "<group>"; };
EE000001EEEE000100000001 /* HiddenPostStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenPostStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -293,6 +301,7 @@
D01E215C9A3306E1A631FA2C /* FavoriteThreadRow.swift */,
E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */,
9CBC04E821A80DD362D9DDF2 /* RecurringMatchSheet.swift */,
BF000003EEEE000100000001 /* FavoritesBackupView.swift */,
9044AE15507088A1373984CA /* AddRecurringFavoriteSheet.swift */,
);
path = Favorites;
Expand Down Expand Up @@ -329,6 +338,7 @@
9510536725660071002E4051 /* Models */ = {
isa = PBXGroup;
children = (
BF000001EEEE000100000001 /* FavoritesBackup.swift */,
EE000001EEEE000100000001 /* HiddenPostStore.swift */,
DD000002DDDD000100000001 /* GeneralSuggestion.swift */,
DD000001DDDD000100000001 /* ThreadReadingProgress.swift */,
Expand Down Expand Up @@ -396,6 +406,7 @@
951817A1254CFB620032616E /* swiftchanTests */ = {
isa = PBXGroup;
children = (
BF000004EEEE000100000001 /* FavoritesBackupTests.swift */,
EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */,
DD000004DDDD000100000001 /* ReadingProgressTests.swift */,
CC000006CCCC000100000006 /* RecurringFavoriteTests.swift */,
Expand Down Expand Up @@ -453,6 +464,7 @@
9580614A255263DB0003FFF3 /* Services */ = {
isa = PBXGroup;
children = (
BF000002EEEE000100000001 /* FavoritesBackupStore.swift */,
DD000003DDDD000100000001 /* ThreadUITestFixture.swift */,
9537082427F801FA00A4DF8F /* MultiActionItem.swift */,
95F6245F255CE64400E9147B /* URLExamples.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand All @@ -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 */,
Expand Down
104 changes: 104 additions & 0 deletions swiftchan/Models/FavoritesBackup.swift
Original file line number Diff line number Diff line change
@@ -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<String>, generalKeys: Set<String>) 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."
}
}
}
}
6 changes: 6 additions & 0 deletions swiftchan/Services/DeepLinker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
43 changes: 43 additions & 0 deletions swiftchan/Services/FavoritesBackupStore.swift
Original file line number Diff line number Diff line change
@@ -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<FavoriteThread>(sortBy: [SortDescriptor(\.savedAt)]))
let generals = try context.fetch(FetchDescriptor<RecurringFavorite>(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
}
}
}
41 changes: 12 additions & 29 deletions swiftchan/ViewModels/RecurringFavoriteViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions swiftchan/Views/Boards/OpenLinkSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading