diff options
| author | Fuwn <[email protected]> | 2026-02-23 09:51:09 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-23 13:33:42 -0800 |
| commit | 33cbce7af9dd26e40e9dd6ba825499eff9abd707 (patch) | |
| tree | 2a5ab11e3e3c1cbd74bf0634970fcf6e817be0b4 | |
| parent | chore: add login localization key (diff) | |
| download | sora-testing-33cbce7af9dd26e40e9dd6ba825499eff9abd707.tar.xz sora-testing-33cbce7af9dd26e40e9dd6ba825499eff9abd707.zip | |
feat: add moebooru held-post visibility setting
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 49 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 3 | ||||
| -rw-r--r-- | Sora/Views/MainView.swift | 7 | ||||
| -rw-r--r-- | Sora/Views/Settings/Section/SettingsSectionProviderView.swift | 4 | ||||
| -rw-r--r-- | SoraTests/ViewDerivedDataTests.swift | 232 |
5 files changed, 281 insertions, 14 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift index 5753089..3c4374e 100644 --- a/Sora/Data/Booru/BooruManager.swift +++ b/Sora/Data/Booru/BooruManager.swift @@ -27,6 +27,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng private let cacheDuration: TimeInterval private let credentials: BooruProviderCredentials? private let userAgent: String + private let showHeldMoebooruPosts: Bool private var urlCache: [String: URL] = [:] private var lastPostCount = 0 @@ -45,13 +46,15 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng init( _ provider: BooruProvider, credentials: BooruProviderCredentials? = nil, - cacheDuration: TimeInterval = BooruPageCacheEntry.defaultExpiration + cacheDuration: TimeInterval = BooruPageCacheEntry.defaultExpiration, + showHeldMoebooruPosts: Bool = false ) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain self.credentials = credentials self.cacheDuration = cacheDuration + self.showHeldMoebooruPosts = showHeldMoebooruPosts pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 @@ -134,7 +137,6 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng flavor: flavor, provider: provider ) - .sorted { $0.createdAt > $1.createdAt } continuation.resume(returning: parsedPosts) } @@ -312,8 +314,31 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng } // MARK: - Private Methods - private func danbooruPageToken(for page: Int) -> String { + private func moebooruTagString(for tags: [String]) -> String { + let hasExplicitHoldsFilter = tags.contains { tag in + tag.lowercased().hasPrefix("holds:") + } + + if hasExplicitHoldsFilter { + return tags.joined(separator: "+") + } + + guard !showHeldMoebooruPosts else { + return tags.joined(separator: "+") + } + + return (tags + ["holds:false"]).joined(separator: "+") + } + + private func hasExplicitSortTag(in tags: [String]) -> Bool { + tags.contains { tag in + tag.lowercased().hasPrefix("order:") + } + } + + private func danbooruPageToken(for page: Int, tags: [String]) -> String { guard page > 1 else { return "1" } + guard !hasExplicitSortTag(in: tags) else { return String(page) } guard let minimumPostID = posts.lazy.compactMap({ Int($0.id) }).min() else { return String(page) @@ -324,7 +349,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng func url(forPosts page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") - let pageCacheValue = flavor == .danbooru ? danbooruPageToken(for: page) : String(page) + let pageCacheValue = + flavor == .danbooru ? danbooruPageToken(for: page, tags: tags) : String(page) let cacheKey = "posts_\(pageCacheValue)_\(limit)_\(tagString.hashValue)" if let cachedURL = urlCache[cacheKey] { @@ -342,7 +368,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng components.path = "/posts.json" var queryItems = [ - URLQueryItem(name: "page", value: danbooruPageToken(for: page)), + URLQueryItem(name: "page", value: danbooruPageToken(for: page, tags: tags)), URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "tags", value: tagString), ] @@ -361,6 +387,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng case .moebooru: var components = URLComponents() + let moebooruTags = moebooruTagString(for: tags) components.scheme = "https" components.host = domain @@ -368,7 +395,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng components.queryItems = [ URLQueryItem(name: "page", value: String(page)), URLQueryItem(name: "limit", value: String(limit)), - URLQueryItem(name: "tags", value: tagString), + URLQueryItem(name: "tags", value: moebooruTags), ] url = components.url @@ -499,13 +526,15 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng flavor == .danbooru ? DanbooruPostParser(data: data).parse() : BooruPostXMLParser(data: data, provider: provider).parse() - var uniquePosts: [String: BooruPost] = [:] - for post in parsedPosts { - uniquePosts[post.id] = post + var seenPostIDs = Set<String>() + var orderedUniquePosts: [BooruPost] = [] + + for post in parsedPosts where seenPostIDs.insert(post.id).inserted { + orderedUniquePosts.append(post) } - return Array(uniquePosts.values) + return orderedUniquePosts } private func updatePosts(_ newPosts: [BooruPost], replace: Bool) { diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift index ddeb1e4..b07b38c 100644 --- a/Sora/Data/Settings/SettingsManager.swift +++ b/Sora/Data/Settings/SettingsManager.swift @@ -35,6 +35,9 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l @AppStorage("uniformThumbnailGrid") private var _uniformThumbnailGrid: Bool = false + @AppStorage("showHeldMoebooruPosts") + var showHeldMoebooruPosts = false + private var syncObservation: NSObjectProtocol? #if os(macOS) diff --git a/Sora/Views/MainView.swift b/Sora/Views/MainView.swift index aa89aa3..770d424 100644 --- a/Sora/Views/MainView.swift +++ b/Sora/Views/MainView.swift @@ -19,6 +19,7 @@ struct MainView: View { } .onAppear(perform: initializeManager) .onChange(of: settings.providerCredentials) { initializeManager() } + .onChange(of: settings.showHeldMoebooruPosts) { initializeManager() } #if os(macOS) .onChange(of: selectedTab) { _, newValue in if newValue == 0 { @@ -119,7 +120,8 @@ struct MainView: View { manager = BooruManager( provider, credentials: settings.providerCredentials - .first { $0.provider == settings.preferredBooru } + .first { $0.provider == settings.preferredBooru }, + showHeldMoebooruPosts: settings.showHeldMoebooruPosts ) manager.searchText = previousSearchText @@ -136,7 +138,8 @@ struct MainView: View { manager = BooruManager( settings.preferredBooru, credentials: settings.providerCredentials - .first { $0.provider == settings.preferredBooru } + .first { $0.provider == settings.preferredBooru }, + showHeldMoebooruPosts: settings.showHeldMoebooruPosts ) Task(priority: .userInitiated) { diff --git a/Sora/Views/Settings/Section/SettingsSectionProviderView.swift b/Sora/Views/Settings/Section/SettingsSectionProviderView.swift index 172ddc6..9ba7610 100644 --- a/Sora/Views/Settings/Section/SettingsSectionProviderView.swift +++ b/Sora/Views/Settings/Section/SettingsSectionProviderView.swift @@ -22,6 +22,10 @@ struct SettingsSectionProviderView: View { } } + Section(header: Text("Moebooru Feed")) { + Toggle("Show Held Posts", isOn: $settings.showHeldMoebooruPosts) + } + Section(header: Text("API Credentials")) { SecureField( "API Key", diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift index 6572b70..9152591 100644 --- a/SoraTests/ViewDerivedDataTests.swift +++ b/SoraTests/ViewDerivedDataTests.swift @@ -323,6 +323,143 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) } + func testBooruManagerMoebooruPostsDefaultToHoldsFalseFilter() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let urlBuilderSection = try extractFunction( + named: "func url(forPosts page: Int, limit: Int, tags: [String]) -> URL?", + from: source + ) + let moebooruCaseStart = try XCTUnwrap(urlBuilderSection.range(of: "case .moebooru:")?.lowerBound) + let moebooruCaseEnd = try XCTUnwrap( + urlBuilderSection.range(of: "case .gelbooru:", range: moebooruCaseStart..<urlBuilderSection.endIndex)? + .lowerBound + ) + let moebooruSection = String(urlBuilderSection[moebooruCaseStart..<moebooruCaseEnd]) + let moebooruTagHelperSection = try extractFunction( + named: "private func moebooruTagString(for tags: [String]) -> String", + from: source + ) + let moebooruTagHelperUsageCount = tokenCount( + matching: #"let\s+moebooruTags\s*=\s*moebooruTagString\(for:\s*tags\)"#, + in: moebooruSection + ) + let holdsFalseDefaultCount = tokenCount( + matching: #"\(tags\s*\+\s*\["holds:false"\]\)\.joined\(separator:\s*"\+"\)"#, + in: moebooruTagHelperSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + moebooruTagHelperUsageCount, + 0, + "Moebooru requests should derive tags through a helper that can enforce feed-visibility filters." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + holdsFalseDefaultCount, + 0, + "Moebooru requests should default to `holds:false` to match website feed visibility." + ) + } + + func testBooruManagerMoebooruRespectsExplicitHoldsTag() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let moebooruTagHelperSection = try extractFunction( + named: "private func moebooruTagString(for tags: [String]) -> String", + from: source + ) + let explicitHoldsDetectionCount = tokenCount( + matching: #"hasPrefix\(\"holds:\"\)"#, + in: moebooruTagHelperSection + ) + let explicitHoldsBypassCount = tokenCount( + matching: #"if\s+hasExplicitHoldsFilter\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#, + in: moebooruTagHelperSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + explicitHoldsDetectionCount, + 0, + "Moebooru tag helper should detect explicit holds filters in search tags." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + explicitHoldsBypassCount, + 0, + "Moebooru tag helper should not override explicit user-supplied `holds:*` filters." + ) + } + + func testSettingsManagerPersistsShowHeldMoebooruPostsFlag() throws { + let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift") + let showHeldSettingCount = tokenCount( + matching: #"\@AppStorage\("showHeldMoebooruPosts"\)\s*var\s+showHeldMoebooruPosts\s*=\s*false"#, + in: source + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + showHeldSettingCount, + 0, + "SettingsManager should persist a `showHeldMoebooruPosts` toggle with a default of false." + ) + } + + func testMainViewPassesShowHeldMoebooruPostsToBooruManager() throws { + let source = try loadSource(at: "Sora/Views/MainView.swift") + let managerShowHeldWiringCount = tokenCount( + matching: #"showHeldMoebooruPosts:\s*settings\.showHeldMoebooruPosts"#, + in: source + ) + let showHeldChangeObserverCount = tokenCount( + matching: #"\.onChange\s*\(\s*of:\s*settings\.showHeldMoebooruPosts\s*\)"#, + in: source + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + managerShowHeldWiringCount, + 1, + "MainView should wire held-post toggle into all BooruManager reconstructions." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + showHeldChangeObserverCount, + 0, + "MainView should refresh BooruManager when held-post visibility setting changes." + ) + } + + func testBooruManagerSupportsShowHeldMoebooruPostsMode() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let initSignatureCount = tokenCount( + matching: #"showHeldMoebooruPosts:\s*Bool\s*=\s*false"#, + in: source + ) + let moebooruTagHelperSection = try extractFunction( + named: "private func moebooruTagString(for tags: [String]) -> String", + from: source + ) + let showHeldBypassCount = tokenCount( + matching: #"guard\s*!\s*showHeldMoebooruPosts\s*else\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#, + in: moebooruTagHelperSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + initSignatureCount, + 0, + "BooruManager should expose a `showHeldMoebooruPosts` initialization option." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + showHeldBypassCount, + 0, + "Moebooru tag helper should bypass forced `holds:false` when showing held posts is enabled." + ) + } + func testBooruManagerDanbooruPostsUseBeforeCursorPagination() throws { let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") let urlBuilderSection = try extractFunction( @@ -336,11 +473,11 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) let danbooruSection = String(urlBuilderSection[danbooruCaseStart..<danbooruCaseEnd]) let pageTokenFunctionSection = try extractFunction( - named: "private func danbooruPageToken(for page: Int) -> String", + named: "private func danbooruPageToken(for page: Int, tags: [String]) -> String", from: source ) let danbooruCursorPageQueryCount = tokenCount( - matching: #"URLQueryItem\(name:\s*"page",\s*value:\s*danbooruPageToken\(for:\s*page\)\)"#, + matching: #"URLQueryItem\(name:\s*"page",\s*value:\s*danbooruPageToken\(for:\s*page,\s*tags:\s*tags\)\)"#, in: danbooruSection ) let beforeCursorCount = tokenCount( @@ -362,6 +499,97 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) } + func testBooruManagerDanbooruPaginationFallsBackToNumericPageForExplicitSort() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let pageTokenFunctionSection = try extractFunction( + named: "private func danbooruPageToken(for page: Int, tags: [String]) -> String", + from: source + ) + let explicitSortGuardCount = tokenCount( + matching: #"guard\s*!\s*hasExplicitSortTag\(in:\s*tags\)\s*else\s*\{\s*return\s*String\(page\)\s*\}"#, + in: pageTokenFunctionSection + ) + let sortTagHelperSection = try extractFunction( + named: "private func hasExplicitSortTag(in tags: [String]) -> Bool", + from: source + ) + let sortTagDetectionCount = tokenCount( + matching: #"hasPrefix\("order:"\)"#, + in: sortTagHelperSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + explicitSortGuardCount, + 0, + "Danbooru pagination should fallback to numeric pages for explicit `order:*` searches." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + sortTagDetectionCount, + 0, + "Danbooru pagination should detect explicit sort tags from search input." + ) + } + + func testBooruManagerDoesNotForceClientSidePostResort() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let retrySection = try extractFunction( + named: "private func fetchPostsWithRetry(url: URL) async -> [BooruPost]", + from: source + ) + let forcedResortCount = tokenCount( + matching: #"\.sorted\s*\{\s*\$0\.createdAt\s*>\s*\$1\.createdAt\s*\}"#, + in: retrySection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertEqual( + forcedResortCount, + 0, + "Fetched posts should preserve provider API ordering and not be force-sorted by created_at." + ) + } + + func testBooruManagerParsePostsPreservesAPIOrderWhenDeduplicating() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let parsePostsSection = try extractFunction( + named: "nonisolated static func parsePosts(", + from: source + ) + let stableDedupeInsertCount = tokenCount( + matching: #"seenPostIDs\.insert\(post\.id\)\.inserted"#, + in: parsePostsSection + ) + let orderedAppendCount = tokenCount( + matching: #"orderedUniquePosts\.append\(post\)"#, + in: parsePostsSection + ) + let orderedReturnCount = tokenCount( + matching: #"return\s+orderedUniquePosts"#, + in: parsePostsSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + stableDedupeInsertCount, + 0, + "Post dedup should track seen IDs without scrambling API order." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + orderedAppendCount, + 0, + "Post dedup should append in parser order to preserve provider ranking." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + orderedReturnCount, + 0, + "Post parser output should return the stable ordered array after deduplication." + ) + } + func testBooruManagerValidatesHTTPStatusCodesForRequests() throws { let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") let requestURLSection = try extractFunction( |