Skip to content

Commit 8a00d3b

Browse files
committed
Plugin recommendations
1 parent f1c7085 commit 8a00d3b

File tree

5 files changed

+162
-30
lines changed

5 files changed

+162
-30
lines changed

Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import WordPressAPI
33
import WordPressAPIInternal
44

5-
public struct RecommendedPlugin {
5+
public struct RecommendedPlugin: Codable, Sendable {
66

77
/// The plugin name – this will be inserted into headers and buttons
88
public let name: String
@@ -48,6 +48,10 @@ public struct RecommendedPlugin {
4848
self.imageUrl = imageUrl
4949
self.helpUrl = helpUrl
5050
}
51+
52+
public var pluginSlug: PluginWpOrgDirectorySlug {
53+
PluginWpOrgDirectorySlug(slug: self.slug)
54+
}
5155
}
5256

5357
public actor PluginRecommendationService {
@@ -139,6 +143,7 @@ public actor PluginRecommendationService {
139143

140144
private let dotOrgClient: WordPressOrgApiClient
141145
private let userDefaults: UserDefaults
146+
private let diskCache = DiskCache()
142147

143148
public init(
144149
dotOrgClient: WordPressOrgApiClient = WordPressOrgApiClient(urlSession: .shared),
@@ -153,12 +158,16 @@ public actor PluginRecommendationService {
153158
}
154159

155160
public func recommendPlugin(for feature: Feature) async throws -> RecommendedPlugin {
161+
if let cachedPlugin = try await fetchCachedPlugin(for: feature.recommendedPlugin.slug) {
162+
return cachedPlugin
163+
}
164+
156165
let plugin = try await dotOrgClient.pluginInformation(slug: feature.recommendedPlugin)
157166

158167
return RecommendedPlugin(
159168
name: plugin.name,
160169
slug: plugin.slug.slug,
161-
usageTitle: "Install \(plugin.name)",
170+
usageTitle: "Install \(plugin.name.removingPercentEncoding ?? plugin.slug.slug)",
162171
usageDescription: feature.explanation,
163172
successMessage: feature.successMessage,
164173
imageUrl: try await cachePluginHeader(for: plugin),
@@ -180,7 +189,7 @@ public actor PluginRecommendationService {
180189
return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp
181190
}
182191

183-
public func didRecommendPlugin(for feature: Feature, at date: Date = Date()) {
192+
public func displayedRecommendation(for feature: Feature, at date: Date = Date()) {
184193
self.userDefaults.set(date.timeIntervalSince1970, forKey: feature.cacheKey)
185194
self.userDefaults.set(date.timeIntervalSince1970, forKey: "plugin-last-recommended")
186195
}
@@ -191,17 +200,39 @@ public actor PluginRecommendationService {
191200
}
192201
self.userDefaults.removeObject(forKey: "plugin-last-recommended")
193202
}
203+
}
204+
205+
// MARK: - RecommendedPlugin Cache
206+
private extension PluginRecommendationService {
207+
private func cachedPluginData(for plugin: RecommendedPlugin) async throws {
208+
let cacheKey = "plugin-recommendation-\(plugin.slug)"
209+
try await self.diskCache.store(object: plugin, for: cacheKey)
210+
}
194211

195-
private func cachePluginHeader(for plugin: PluginInformation) async throws -> URL? {
212+
private func fetchCachedPlugin(for slug: String) async throws -> RecommendedPlugin? {
213+
let cacheKey = "plugin-recommendation-\(slug)"
214+
return try await self.diskCache.retrieve(for: cacheKey, notOlderThan: Date().addingTimeInterval(-86_400))
215+
}
216+
}
217+
218+
// MARK: - Plugin Banner Cache
219+
private extension PluginRecommendationService {
220+
func cachePluginHeader(for plugin: PluginInformation) async throws -> URL? {
196221
guard let pluginUrl = plugin.bannerUrl, let bannerFileName = plugin.bannerFileName else {
197222
return nil
198223
}
199224

200225
let cachePath = self.storagePath(for: plugin, filename: bannerFileName)
226+
227+
try FileManager.default.createDirectory(
228+
at: cachePath.deletingLastPathComponent(),
229+
withIntermediateDirectories: true
230+
)
231+
201232
return try await cacheAsset(pluginUrl, at: cachePath)
202233
}
203234

204-
private func cacheAsset(_ url: URL, at path: URL) async throws -> URL {
235+
func cacheAsset(_ url: URL, at path: URL) async throws -> URL {
205236
if FileManager.default.fileExists(at: path) {
206237
return path
207238
}
@@ -212,7 +243,7 @@ public actor PluginRecommendationService {
212243
return path
213244
}
214245

215-
private func storagePath(for plugin: PluginInformation, filename: String) -> URL {
246+
func storagePath(for plugin: PluginInformation, filename: String) -> URL {
216247
URL.cachesDirectory
217248
.appendingPathComponent("plugin-assets")
218249
.appendingPathComponent(plugin.slug.slug)

Modules/Sources/WordPressCore/Plugins/PluginService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public actor PluginService: PluginServiceProtocol {
4444
try await pluginDirectoryDataStore.store([plugin])
4545
}
4646

47+
public func hasInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> Bool {
48+
try await findInstalledPlugin(slug: slug) != nil
49+
}
50+
4751
public func findInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> InstalledPlugin? {
4852
try await installedPluginDataStore.list(query: .slug(slug)).first
4953
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,58 @@
11
import Foundation
22
import WordPressAPI
3+
import WordPressAPIInternal
34

45
public actor WordPressClient {
56

7+
public enum Feature {
8+
case themeStyles
9+
case applicationPasswordExtras
10+
case managePlugins
11+
}
12+
613
public let api: WordPressAPI
714
public let rootUrl: String
815

16+
private var apiRoot: WpApiDetails?
17+
918
public init(api: WordPressAPI, rootUrl: ParsedUrl) {
1019
self.api = api
1120
self.rootUrl = rootUrl.url()
1221
}
22+
23+
public func refreshCachedSiteInfo() async throws {
24+
let apiRoot = try await self.api.apiRoot.get()
25+
self.apiRoot = apiRoot.data
26+
}
27+
28+
public func currentUserCan(_ capability: String) async throws -> Bool {
29+
false
30+
}
31+
32+
public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool {
33+
let apiRoot = try await fetchApiRoot()
34+
35+
if let siteId {
36+
return switch feature {
37+
case .themeStyles: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings")
38+
case .managePlugins: apiRoot.hasRoute(route: "/wp/v2/plugins")
39+
case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax")
40+
}
41+
}
42+
43+
return switch feature {
44+
case .themeStyles: apiRoot.hasRoute(route: "/wp-block-editor/v1/settings")
45+
case .managePlugins: apiRoot.hasRoute(route: "/wp/v2/plugins")
46+
case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax")
47+
}
48+
}
49+
50+
private func fetchApiRoot() async throws -> WpApiDetails {
51+
if let apiRoot = self.apiRoot {
52+
return apiRoot
53+
}
54+
let apiRoot = try await self.api.apiRoot.get()
55+
self.apiRoot = apiRoot.data
56+
return apiRoot.data
57+
}
1358
}

WordPress/Classes/Networking/WordPressClient.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier {
138138
let blogId = site.blogId(in: coreDataStack)
139139
NotificationCenter.default.post(name: WordPressClient.requestedWithInvalidAuthenticationNotification, object: blogId)
140140
}
141+
141142
}
142143

143144
private extension WordPressSite {

WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
135135
private var suggestionViewBottomConstraint: NSLayoutConstraint?
136136
private var currentSuggestionsController: GutenbergSuggestionsViewController?
137137

138-
private var editorState: EditorLoadingState = .uninitialized
138+
private var editorState: EditorLoadingState = .uninitialized {
139+
willSet {
140+
// TODO: Cancel tasks
141+
}
142+
didSet {
143+
self.evaluateEditorState()
144+
}
145+
}
139146
private var dependencyLoadingError: Error?
140147
private var editorLoadingTask: Task<Void, Error>?
141148

@@ -156,6 +163,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
156163
func getHTML() -> String { post.content ?? "" }
157164

158165
private let blockEditorSettingsService: RawBlockEditorSettingsService
166+
private let pluginRecommendationService = PluginRecommendationService()
159167

160168
// MARK: - Initializers
161169
required convenience init(
@@ -275,7 +283,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
275283
case .loadingCancelled: preconditionFailure("Dependency loading should not be cancelled")
276284
case .suggestingPlugin(let plugin): self.recommendPlugin(plugin)
277285
case .dependencyError(let error): self.showEditorError(error)
278-
case .dependenciesReady(let dependencies): try await self.startEditor(settings: dependencies.settings)
286+
case .dependenciesReady(let dependencies): self.startEditor(settings: dependencies.settings)
279287
case .started: preconditionFailure("The editor should not already be started")
280288
}
281289
} catch {
@@ -361,7 +369,15 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
361369
}
362370

363371
func showEditorError(_ error: Error) {
364-
// TODO: We should have a unified way to do this
372+
let controller = UIAlertController(
373+
title: "Error loading editor",
374+
message: error.localizedDescription,
375+
preferredStyle: .actionSheet
376+
)
377+
378+
controller.addAction(UIAlertAction(title: "Dismiss", style: .cancel))
379+
380+
self.present(controller, animated: true)
365381
}
366382

367383
func showFeedbackView() {
@@ -374,6 +390,18 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
374390
}
375391
}
376392

393+
func evaluateEditorState() {
394+
switch self.editorState {
395+
case .uninitialized: break
396+
case .loadingDependencies: break
397+
case .loadingCancelled: break
398+
case .suggestingPlugin(let plugin): self.recommendPlugin(plugin)
399+
case .dependencyError(let error): self.showEditorError(error)
400+
case .dependenciesReady(let dependencies): self.startEditor(settings: dependencies.settings)
401+
case .started: break
402+
}
403+
}
404+
377405
func startLoadingDependencies() {
378406
switch self.editorState {
379407
case .uninitialized:
@@ -396,7 +424,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
396424
do {
397425
try await fetchEditorDependencies()
398426
} catch {
399-
self.editorState = .dependencyError(error)
427+
await MainActor.run {
428+
self.editorState = .dependencyError(error)
429+
}
400430
}
401431
})
402432
}
@@ -422,7 +452,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
422452
}
423453

424454
@MainActor
425-
func startEditor(settings: String?) async throws {
455+
func startEditor(settings: String?) {
426456
guard case .dependenciesReady = self.editorState else {
427457
preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.")
428458
}
@@ -508,39 +538,60 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
508538

509539
// MARK: - Editor Setup
510540
private func fetchEditorDependencies() async throws {
511-
let site = try await ContextManager.shared.performQuery { context in
541+
let (site, dotComID) = try await ContextManager.shared.performQuery { context in
512542
let blog = try context.existingObject(with: self.blogID)
513-
return try WordPressSite(blog: blog)
543+
return (try WordPressSite(blog: blog), blog.dotComID?.intValue)
514544
}
515545

516546
let client = WordPressClient(site: site)
517-
let pluginService = PluginService(client: client, wordpressCoreVersion: nil)
518-
let pluginRecommendationService = PluginRecommendationService()
519547

520-
let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility]
548+
if let plugin = try await self.fetchPluginRecommendation(client: client) {
549+
self.editorState = .suggestingPlugin(plugin)
550+
return
551+
}
521552

522-
// Don't make plugin recommendations for WordPress
523-
if AppConfiguration.isJetpack {
524-
for feature in features {
525-
if pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) {
526-
let plugin = try await pluginRecommendationService.recommendPlugin(for: feature)
527-
528-
guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else {
529-
pluginRecommendationService.didRecommendPlugin(for: feature)
530-
self.editorState = .suggestingPlugin(plugin)
531-
return
532-
}
533-
}
534-
}
553+
let settings: String?
554+
555+
if try await client.supports(.themeStyles, forSiteId: dotComID) {
556+
settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true)
535557
}
536558

537-
let settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true)
538559
let loaded = await loadAuthenticationCookiesAsync()
539560

540561
let dependencies = EditorDependencies(settings: settings, didLoadCookies: loaded)
541562
self.editorState = .dependenciesReady(dependencies)
542563
}
543564

565+
private func fetchPluginRecommendation(client: WordPressClient) async throws -> RecommendedPlugin? {
566+
567+
guard try await client.supports(.managePlugins) else {
568+
return nil
569+
}
570+
571+
let pluginService = PluginService(client: client, wordpressCoreVersion: nil)
572+
try await pluginService.fetchInstalledPlugins()
573+
574+
// Don't make plugin recommendations for WordPress – that app only supports features available in Core
575+
guard AppConfiguration.isJetpack else {
576+
return nil
577+
}
578+
579+
let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility]
580+
581+
for feature in features {
582+
if await pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) {
583+
let plugin = try await pluginRecommendationService.recommendPlugin(for: feature)
584+
585+
guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else {
586+
await pluginRecommendationService.displayedRecommendation(for: feature)
587+
return plugin
588+
}
589+
}
590+
}
591+
592+
return nil
593+
}
594+
544595
private func loadAuthenticationCookiesAsync() async -> Bool {
545596
guard post.blog.isPrivate() else {
546597
return true

0 commit comments

Comments
 (0)