diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 4cb6fb035a49..e9f83ce57a9a 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,6 +4,7 @@ * [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666] * [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688] * [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753] +* [*] Fix categories and tags not working on self-hosted sites with XML-RPC disabled [#25767] * [*] Fix Blogging Reminders text color in dark mode [#25780] 27.0 diff --git a/WordPress/Classes/Services/PostCategoryService.m b/WordPress/Classes/Services/PostCategoryService.m index 55b26097d8e6..8fa51d3f946a 100644 --- a/WordPress/Classes/Services/PostCategoryService.m +++ b/WordPress/Classes/Services/PostCategoryService.m @@ -184,8 +184,16 @@ - (void)mergeCategories:(NSArray *)remoteCategories forBl if (blog.wordPressComRestApi) { return [[TaxonomyServiceRemoteREST alloc] initWithWordPressComRestApi:blog.wordPressComRestApi siteID:blog.dotComID]; } - } else if (blog.xmlrpcApi) { - return [[TaxonomyServiceRemoteXMLRPC alloc] initWithApi:blog.xmlrpcApi username:blog.username password:blog.password]; + } else { + // Prefer the core REST API (wp/v2) for self-hosted sites. Some hosts + // block the XML-RPC term methods, which breaks categories (#25758). + TaxonomyServiceRemoteCoreREST *coreREST = [[TaxonomyServiceRemoteCoreREST alloc] initWithBlog:blog]; + if (coreREST) { + return coreREST; + } + if (blog.xmlrpcApi) { + return [[TaxonomyServiceRemoteXMLRPC alloc] initWithApi:blog.xmlrpcApi username:blog.username password:blog.password]; + } } return nil; } diff --git a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift index 868796e36aa6..71b9b18457fb 100644 --- a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift +++ b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift @@ -17,7 +17,11 @@ import WordPressAPI self.client = client } - public func createCategory(_ category: RemotePostCategory, success: ((RemotePostCategory) -> Void)?, failure: ((any Error) -> Void)? = nil) { + public func createCategory( + _ category: RemotePostCategory, + success: ((RemotePostCategory) -> Void)?, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { let params = TermCreateParams( @@ -33,10 +37,13 @@ import WordPressAPI } } - public func getCategoriesWithSuccess(_ success: @escaping ([RemotePostCategory]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func getCategoriesWithSuccess( + _ success: @escaping ([RemotePostCategory]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { - let sequence = await client.api.terms.sequenceWithEditContext( + let sequence = await client.api.terms.sequenceWithViewContext( type: .categories, params: TermListParams(perPage: 100) ) @@ -51,7 +58,11 @@ import WordPressAPI } } - public func getCategoriesWith(_ paging: RemoteTaxonomyPaging, success: @escaping ([RemotePostCategory]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func getCategoriesWith( + _ paging: RemoteTaxonomyPaging, + success: @escaping ([RemotePostCategory]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { let params = TermListParams( @@ -61,7 +72,7 @@ import WordPressAPI order: WpApiParamOrder(paging.order), orderby: WpApiParamTermsOrderBy(paging.orderBy) ) - let response = try await client.api.terms.listWithEditContext( + let response = try await client.api.terms.listWithViewContext( termEndpointType: .categories, params: params ) @@ -73,11 +84,15 @@ import WordPressAPI } } - public func searchCategories(withName nameQuery: String, success: @escaping ([RemotePostCategory]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func searchCategories( + withName nameQuery: String, + success: @escaping ([RemotePostCategory]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { let params = TermListParams(search: nameQuery) - let response = try await client.api.terms.listWithEditContext( + let response = try await client.api.terms.listWithViewContext( termEndpointType: .categories, params: params ) @@ -89,7 +104,11 @@ import WordPressAPI } } - public func createTag(_ tag: RemotePostTag, success: ((RemotePostTag) -> Void)?, failure: ((any Error) -> Void)? = nil) { + public func createTag( + _ tag: RemotePostTag, + success: ((RemotePostTag) -> Void)?, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { let params = TermCreateParams( @@ -109,7 +128,11 @@ import WordPressAPI } } - public func update(_ tag: RemotePostTag, success: ((RemotePostTag) -> Void)?, failure: ((any Error) -> Void)? = nil) { + public func update( + _ tag: RemotePostTag, + success: ((RemotePostTag) -> Void)?, + failure: ((any Error) -> Void)? = nil + ) { guard let tagID = tag.tagID else { failure?(URLError(.unknown)) return @@ -151,10 +174,13 @@ import WordPressAPI } } - public func getTagsWithSuccess(_ success: @escaping ([RemotePostTag]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func getTagsWithSuccess( + _ success: @escaping ([RemotePostTag]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { - let response = try await client.api.terms.listWithEditContext( + let response = try await client.api.terms.listWithViewContext( termEndpointType: .tags, params: TermListParams() ) @@ -166,7 +192,11 @@ import WordPressAPI } } - public func getTagsWith(_ paging: RemoteTaxonomyPaging, success: @escaping ([RemotePostTag]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func getTagsWith( + _ paging: RemoteTaxonomyPaging, + success: @escaping ([RemotePostTag]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { let params = TermListParams( @@ -176,7 +206,7 @@ import WordPressAPI order: WpApiParamOrder(paging.order), orderby: WpApiParamTermsOrderBy(paging.orderBy) ) - let response = try await client.api.terms.listWithEditContext( + let response = try await client.api.terms.listWithViewContext( termEndpointType: .tags, params: params ) @@ -188,10 +218,14 @@ import WordPressAPI } } - public func searchTags(withName nameQuery: String, success: @escaping ([RemotePostTag]) -> Void, failure: ((any Error) -> Void)? = nil) { + public func searchTags( + withName nameQuery: String, + success: @escaping ([RemotePostTag]) -> Void, + failure: ((any Error) -> Void)? = nil + ) { Task { @MainActor in do { - let response = try await client.api.terms.listWithEditContext( + let response = try await client.api.terms.listWithViewContext( termEndpointType: .tags, params: TermListParams(search: nameQuery) ) @@ -205,6 +239,13 @@ import WordPressAPI } private extension RemotePostCategory { + convenience init(category: AnyTermWithViewContext) { + self.init() + self.categoryID = NSNumber(value: category.id) + self.name = category.name + self.parentID = NSNumber(value: category.parent ?? 0) + } + convenience init(category: AnyTermWithEditContext) { self.init() self.categoryID = NSNumber(value: category.id) @@ -214,6 +255,15 @@ private extension RemotePostCategory { } private extension RemotePostTag { + convenience init(tag: AnyTermWithViewContext) { + self.init() + self.tagID = NSNumber(value: tag.id) + self.name = tag.name + self.slug = tag.slug + self.tagDescription = tag.description + self.postCount = NSNumber(value: tag.count) + } + convenience init(tag: AnyTermWithEditContext) { self.init() self.tagID = NSNumber(value: tag.id) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index a96aafabeff8..906d650d6265 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -62,7 +62,7 @@ extension SiteSettingsViewController { @objc public func showCustomTaxonomies() { let viewController: UIViewController if let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)) { - let rootView = SiteCustomTaxonomiesView(blog: self.blog, client: client) + let rootView = SiteCustomTaxonomiesView(client: client) viewController = UIHostingController(rootView: rootView) } else { let feature = NSLocalizedString( @@ -76,7 +76,7 @@ extension SiteSettingsViewController { source: "taxonomies", presentingViewController: self ) { client in - SiteCustomTaxonomiesView(blog: self.blog, client: client) + SiteCustomTaxonomiesView(client: client) } viewController = UIHostingController(rootView: rootView) } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift index 58f10c8cdbb0..6b86070bf53c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift @@ -243,7 +243,6 @@ struct PostSettingsFormContentView: Vi ForEach(viewModel.customTaxonomies, id: \.slug) { taxonomy in NavigationLink { PostTagsView( - blog: viewModel.blog, client: client, taxonomy: taxonomy, selectedTerms: viewModel.settings.getTerms(forTaxonomySlug: taxonomy.slug) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 2f66e9874b40..9a9e20df70bc 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -302,7 +302,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM Task { [weak self] in guard let self else { return } - let service = TagsService(blog: blog) + let service = TagsViewModel.makeTagsService(for: blog) let resolved = await service.resolveTerms(named: pendingNames) for (name, existing) in resolved { if let index = settings.tags.firstIndex(where: { $0.name == name }) { diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostTagsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostTagsView.swift index 201086620873..8138f87de456 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostTagsView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/PostTagsView.swift @@ -12,14 +12,14 @@ struct PostTagsView: View { @State private var isKeyboardPresented = false init(blog: Blog, selectedTags: [TagsViewModel.SelectedTerm], onSelectionChanged: @escaping ([TagsViewModel.SelectedTerm]) -> Void) { - let viewModel = TagsViewModel(blog: blog, selectedTags: selectedTags, mode: .selection(onSelectedTagsChanged: { tags in + let viewModel = TagsViewModel.tags(for: blog, selectedTerms: selectedTags, mode: .selection(onSelectedTagsChanged: { tags in onSelectionChanged(tags) })) self._viewModel = StateObject(wrappedValue: viewModel) } - init(blog: Blog, client: WordPressClient, taxonomy: SiteTaxonomy, selectedTerms: [TagsViewModel.SelectedTerm] = [], onSelectionChanged: @escaping ([TagsViewModel.SelectedTerm]) -> Void) { - let viewModel = TagsViewModel(blog: blog, client: client, taxonomy: taxonomy, selectedTerms: selectedTerms, mode: .selection(onSelectedTagsChanged: { tags in + init(client: WordPressClient, taxonomy: SiteTaxonomy, selectedTerms: [TagsViewModel.SelectedTerm] = [], onSelectionChanged: @escaping ([TagsViewModel.SelectedTerm]) -> Void) { + let viewModel = TagsViewModel.taxonomy(taxonomy, client: client, selectedTerms: selectedTerms, mode: .selection(onSelectedTagsChanged: { tags in onSelectionChanged(tags) })) self._viewModel = StateObject(wrappedValue: viewModel) diff --git a/WordPress/Classes/ViewRelated/Tags/SiteCustomTaxonomiesView.swift b/WordPress/Classes/ViewRelated/Tags/SiteCustomTaxonomiesView.swift index f1c2815ac3c9..3baa87a9fb46 100644 --- a/WordPress/Classes/ViewRelated/Tags/SiteCustomTaxonomiesView.swift +++ b/WordPress/Classes/ViewRelated/Tags/SiteCustomTaxonomiesView.swift @@ -7,15 +7,13 @@ import WordPressShared import WordPressUI struct SiteCustomTaxonomiesView: View { - let blog: Blog let client: WordPressClient @State private var isLoading: Bool = false @State private var taxonomies: [SiteTaxonomy]? = nil @State private var error: Error? - init(blog: Blog, client: WordPressClient) { - self.blog = blog + init(client: WordPressClient) { self.client = client } @@ -23,7 +21,7 @@ struct SiteCustomTaxonomiesView: View { List { ForEach(taxonomies ?? [], id: \.slug) { taxonomy in NavigationLink { - SiteTagsView(viewModel: .init(blog: blog, client: client, taxonomy: taxonomy, mode: .browse)) + SiteTagsView(viewModel: .taxonomy(taxonomy, client: client, mode: .browse)) } label: { Text(taxonomy.localizedName) } diff --git a/WordPress/Classes/ViewRelated/Tags/SiteTagsView.swift b/WordPress/Classes/ViewRelated/Tags/SiteTagsView.swift index ce82ff1f3389..6ce0d49332f6 100644 --- a/WordPress/Classes/ViewRelated/Tags/SiteTagsView.swift +++ b/WordPress/Classes/ViewRelated/Tags/SiteTagsView.swift @@ -193,7 +193,7 @@ class SiteTagsViewController: UIHostingController { let viewModel: TagsViewModel init(blog: Blog) { - viewModel = TagsViewModel(blog: blog, mode: .browse) + viewModel = TagsViewModel.tags(for: blog, mode: .browse) super.init(rootView: .init(viewModel: viewModel)) } diff --git a/WordPress/Classes/ViewRelated/Tags/TagsService.swift b/WordPress/Classes/ViewRelated/Tags/TagsService.swift index 7c7dc785719b..f2fb090bcad9 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsService.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsService.swift @@ -6,7 +6,7 @@ import WordPressAPI import WordPressAPIInternal protocol TaxonomyServiceProtocol { - func getTags(page: Int, recentlyUsed: Bool) async throws -> [AnyTermWithViewContext] + func getTags(page: Int, recentlyUsed: Bool) async throws -> (terms: [AnyTermWithViewContext], hasMore: Bool) func searchTags(with query: String) async throws -> [AnyTermWithViewContext] func createTag(name: String, description: String) async throws -> AnyTermWithViewContext func updateTag(_ term: AnyTermWithViewContext, name: String, description: String) async throws -> AnyTermWithViewContext @@ -38,7 +38,7 @@ class TagsService: TaxonomyServiceProtocol { func getTags( page: Int = 0, recentlyUsed: Bool = false - ) async throws -> [AnyTermWithViewContext] { + ) async throws -> (terms: [AnyTermWithViewContext], hasMore: Bool) { guard let remote else { throw TagsServiceError.noRemoteService } @@ -52,7 +52,8 @@ class TagsService: TaxonomyServiceProtocol { return try await withCheckedThrowingContinuation { continuation in remote.getTagsWith(paging, success: { remoteTags in - continuation.resume(returning: remoteTags.map { AnyTermWithViewContext(tag: $0) }) + let terms = remoteTags.map { AnyTermWithViewContext(tag: $0) } + continuation.resume(returning: (terms, terms.count == pageSize)) }, failure: { error in continuation.resume(throwing: error) }) @@ -254,7 +255,10 @@ class AnyTermService: TaxonomyServiceProtocol { self.client = client } - func getTags(page: Int = 0, recentlyUsed: Bool = false) async throws -> [AnyTermWithViewContext] { + func getTags( + page: Int = 0, + recentlyUsed: Bool = false + ) async throws -> (terms: [AnyTermWithViewContext], hasMore: Bool) { let perPage: UInt32 = 100 let params = TermListParams( page: UInt32(page + 1), @@ -268,7 +272,7 @@ class AnyTermService: TaxonomyServiceProtocol { params: params ) - return response.data + return (response.data, response.nextPageParams != nil) } func searchTags(with query: String) async throws -> [AnyTermWithViewContext] { diff --git a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift index b71811046259..abe84952dd15 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift @@ -57,18 +57,32 @@ class TagsViewModel: ObservableObject { return false } - convenience init(blog: Blog, selectedTags: [SelectedTerm] = [], mode: TagsViewMode) { - self.init(taxonomy: nil, service: TagsService(blog: blog), selectedTerms: selectedTags, mode: mode) + /// Builds a view model for a site's built-in tags. `makeTagsService(for:)` + /// picks the backing service so self-hosted sites use REST where they can + /// (issue #25758). + static func tags( + for blog: Blog, + selectedTerms: [SelectedTerm] = [], + mode: TagsViewMode + ) -> TagsViewModel { + TagsViewModel( + taxonomy: nil, + service: makeTagsService(for: blog), + selectedTerms: selectedTerms, + mode: mode + ) } - convenience init( - blog: Blog, + /// Builds a view model for a specific site `taxonomy` (e.g. a custom + /// taxonomy). Always uses the core REST API, which a custom taxonomy's + /// `client` already guarantees is available. + static func taxonomy( + _ taxonomy: SiteTaxonomy, client: WordPressClient, - taxonomy: SiteTaxonomy, selectedTerms: [SelectedTerm] = [], mode: TagsViewMode - ) { - self.init( + ) -> TagsViewModel { + TagsViewModel( taxonomy: taxonomy, service: AnyTermService(client: client, endpoint: taxonomy.endpoint), selectedTerms: selectedTerms, @@ -76,6 +90,8 @@ class TagsViewModel: ObservableObject { ) } + /// Designated initializer. Inject a `service` directly; production code + /// should prefer the `tags(for:)` / `taxonomy(_:client:)` factories. init( taxonomy: SiteTaxonomy?, service: TaxonomyServiceProtocol, @@ -90,6 +106,24 @@ class TagsViewModel: ObservableObject { self.selectedTagsSet = Set(selectedTerms.map { $0.name.lowercased() }) } + /// Chooses the taxonomy service backing a blog's tags. + /// + /// Sites with core REST API access (WordPress.com, or self-hosted sites with + /// an application password) use `AnyTermService` so tags go through the wp/v2 + /// REST API. Some hosts block the XML-RPC term methods that `TagsService` + /// uses for self-hosted sites, which breaks tags entirely (issue #25758). + /// Legacy self-hosted sites without REST access keep using `TagsService`. + static func makeTagsService( + for blog: Blog, + keychain: KeychainAccessible = AppKeychain() + ) -> TaxonomyServiceProtocol { + if let site = try? WordPressSite(blog: blog, keychain: keychain) { + let client = WordPressClientFactory.shared.instance(for: site) + return AnyTermService(client: client, endpoint: .tags) + } + return TagsService(blog: blog) + } + func onAppear() { guard response == nil else { return } Task { @@ -118,16 +152,16 @@ class TagsViewModel: ObservableObject { } let page = pageIndex ?? 0 - let remoteTags = try await self.tagsService.getTags( + let result = try await self.tagsService.getTags( page: page, recentlyUsed: !self.isBrowseMode ) - let hasMore = remoteTags.count == 100 + let hasMore = result.hasMore let nextPage = hasMore ? page + 1 : nil return TagsPaginatedResponse.Page( - items: remoteTags, + items: result.terms, total: nil, hasMore: hasMore, nextPage: nextPage