Skip to content
Open
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions WordPress/Classes/Services/PostCategoryService.m
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,16 @@ - (void)mergeCategories:(NSArray <RemotePostCategory *> *)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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a reminder to myself: we need to make sure categories can be fully fetched, especially on atomic sites(I think?).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have verified that all categories can be fetched.

if (blog.xmlrpcApi) {
return [[TaxonomyServiceRemoteXMLRPC alloc] initWithApi:blog.xmlrpcApi username:blog.username password:blog.password];
}
}
return nil;
}
Expand Down
80 changes: 65 additions & 15 deletions WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
)
Expand All @@ -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(
Expand All @@ -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
)
Expand All @@ -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
)
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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()
)
Expand All @@ -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(
Expand All @@ -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
)
Expand All @@ -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)
)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ struct PostSettingsFormContentView<ViewModel: PostSettingsViewModelProtocol>: Vi
ForEach(viewModel.customTaxonomies, id: \.slug) { taxonomy in
NavigationLink {
PostTagsView(
blog: viewModel.blog,
client: client,
taxonomy: taxonomy,
selectedTerms: viewModel.settings.getTerms(forTaxonomySlug: taxonomy.slug)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,21 @@ 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
}

var body: some 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)
}
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/ViewRelated/Tags/SiteTagsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class SiteTagsViewController: UIHostingController<SiteTagsView> {
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))
}

Expand Down
48 changes: 41 additions & 7 deletions WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,41 @@ 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,
mode: mode
)
}

/// Designated initializer. Inject a `service` directly; production code
/// should prefer the `tags(for:)` / `taxonomy(_:client:)` factories.
init(
taxonomy: SiteTaxonomy?,
service: TaxonomyServiceProtocol,
Expand All @@ -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 {
Expand Down