diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 5184c26c0..40225ae22 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -737,6 +737,65 @@ class GutenbergView : FrameLayout { } } + private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf()) + + /** + * Triggers the editor store's save lifecycle and invokes [callback] when it completes. + * + * This drives the WordPress `core/editor` store through its full save flow, causing + * `isSavingPost()` to transition `true` → `false`. Plugins that subscribe to this + * lifecycle (e.g., VideoPress syncing metadata via `/wpcom/v2/videopress/meta`) fire + * their side-effect API calls during this transition. + * + * The actual post content is **not** persisted by this method — the host app is + * responsible for reading content via [getTitleAndContent] and saving it through + * its own REST API calls. The callback fires only after the editor store's save + * lifecycle completes, so it is safe to read and persist content at that point. + * + * **Important:** if the callback reports `success = false` (for example because a + * third-party plugin subscribed to the lifecycle errored out), hosts should still + * proceed to read and persist content. A misbehaving plugin must not block the + * user from saving their work — log the lifecycle failure and continue. + * + * Note: `window.editor.savePost()` is an async JS function that returns a Promise. + * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's + * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion + * back via the `editorDelegate` JavaScript interface. + */ + fun savePost(callback: SavePostCallback) { + if (!isEditorLoaded) { + Log.e("GutenbergView", "You can't save until the editor has loaded") + callback.onComplete(false, "Editor not loaded") + return + } + val requestId = java.util.UUID.randomUUID().toString() + pendingSaveCallbacks[requestId] = callback + // Quote the requestId for safe JS string interpolation. UUIDs are safe + // today, but routing all values through `JSONObject.quote()` ensures we + // never accidentally inject untrusted strings into the JS context. + val quotedRequestId = JSONObject.quote(requestId) + handler.post { + webView.evaluateJavascript( + "editor.savePost()" + + ".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" + + ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, String(e)));", + null + ) + } + } + + @JavascriptInterface + fun onSavePostComplete(requestId: String, success: Boolean, error: String?) { + val callback = pendingSaveCallbacks.remove(requestId) ?: return + handler.post { + callback.onComplete(success, error) + } + } + + fun interface SavePostCallback { + fun onComplete(success: Boolean, error: String?) + } + fun appendTextAtCursor(text: String) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't append text until the editor has loaded") @@ -1025,10 +1084,23 @@ class GutenbergView : FrameLayout { networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null + // Fail any save callbacks still waiting on a JS Promise — without this, + // coroutines awaiting `savePost()` would hang forever (and leak whatever + // they captured) when the view is torn down mid-save. + drainPendingSaveCallbacks("View detached") handler.removeCallbacksAndMessages(null) webView.destroy() } + private fun drainPendingSaveCallbacks(reason: String) { + val pending = synchronized(pendingSaveCallbacks) { + val snapshot = pendingSaveCallbacks.toMap() + pendingSaveCallbacks.clear() + snapshot + } + pending.values.forEach { it.onComplete(false, reason) } + } + // Network Monitoring private fun startNetworkMonitoring() { diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 7a3eee0cc..692ca79ca 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -75,7 +75,9 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return buildNamespacedUrl("/wp/v2/posts/$id?context=edit") + val restNamespace = configuration.postType.restNamespace + val restBase = configuration.postType.restBase + return buildNamespacedUrl("/$restNamespace/$restBase/$id?context=edit") } // MARK: Editor Settings diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 05fe263ed..2a7293193 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -11,7 +11,7 @@ data class EditorConfiguration( val title: String, val content: String, val postId: UInt?, - val postType: String, + val postType: PostTypeDetails, val postStatus: String, val themeStyles: Boolean, val plugins: Boolean, @@ -46,15 +46,15 @@ data class EditorConfiguration( } companion object { @JvmStatic - fun builder(siteURL: String, siteApiRoot: String, postType: String = "post"): Builder = Builder(siteURL, siteApiRoot, postType = postType) + fun builder(siteURL: String, siteApiRoot: String, postType: PostTypeDetails = PostTypeDetails.post): Builder = Builder(siteURL, siteApiRoot, postType = postType) @JvmStatic - fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", "post") + fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", PostTypeDetails.post) .setEnableOfflineMode(true) .build() } - class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { + class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: PostTypeDetails) { private var title: String = "" private var content: String = "" private var postId: UInt? = null @@ -77,7 +77,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: UInt?) = apply { this.postId = postId?.takeIf { it != 0u } } - fun setPostType(postType: String) = apply { this.postType = postType } + fun setPostType(postType: PostTypeDetails) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt index 087e8269b..206da830a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt @@ -28,8 +28,8 @@ data class EditorPreloadList private constructor( val postID: Int?, /** The pre-fetched post data for the post being edited. */ val postData: EditorURLResponse?, - /** The post type identifier (e.g., "post", "page"). */ - val postType: String, + /** Details about the post type, including REST API configuration. */ + val postType: PostTypeDetails, /** Pre-fetched data for the current post type's schema. */ val postTypeData: EditorURLResponse, /** Pre-fetched data for all available post types. */ @@ -47,7 +47,7 @@ data class EditorPreloadList private constructor( constructor( postID: Int? = null, postData: EditorURLResponse? = null, - postType: String, + postType: PostTypeDetails, postTypeData: EditorURLResponse, postTypesData: EditorURLResponse, activeThemeData: EditorURLResponse?, @@ -72,7 +72,7 @@ data class EditorPreloadList private constructor( fun build(): JsonElement { val entries = mutableMapOf() - entries[buildPostTypePath(postType)] = postTypeData.toJsonElement() + entries[buildPostTypePath(postType.postType)] = postTypeData.toJsonElement() entries[POST_TYPES_PATH] = postTypesData.toJsonElement() if (postID != null && postData != null) { @@ -115,7 +115,8 @@ data class EditorPreloadList private constructor( } /** Builds the API path for fetching a specific post. */ - private fun buildPostPath(id: Int): String = "/wp/v2/posts/$id?context=edit" + private fun buildPostPath(id: Int): String = + "/${postType.restNamespace}/${postType.restBase}/$id?context=edit" /** Builds the API path for fetching a post type's schema. */ private fun buildPostTypePath(type: String): String = "/wp/v2/types/$type?context=edit" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index fdc6568f5..4ec3ff1e2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -74,6 +74,10 @@ data class GBKitGlobal( val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts /** The post type (e.g., `post`, `page`). */ val type: String, + /** The REST API base path for this post type (e.g., `posts`, `pages`). */ + val restBase: String, + /** The REST API namespace (e.g., `wp/v2`). */ + val restNamespace: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ val status: String, /** The post title (URL-encoded). */ @@ -109,7 +113,9 @@ data class GBKitGlobal( locale = configuration.locale ?: "en", post = Post( id = postId ?: -1, - type = configuration.postType, + type = configuration.postType.postType, + restBase = configuration.postType.restBase, + restNamespace = configuration.postType.restNamespace, status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt new file mode 100644 index 000000000..0625bb9a4 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt @@ -0,0 +1,44 @@ +package org.wordpress.gutenberg.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Details about a WordPress post type needed for REST API interactions. + * + * This class encapsulates the information required to construct correct REST API + * endpoints for different post types. WordPress custom post types (like WooCommerce + * products) have their own REST endpoints that differ from the standard `/wp/v2/posts/`. + * + * For standard post types, use the provided constants: + * ```kotlin + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.post) + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.page) + * ``` + * + * For custom post types, create an instance with the appropriate REST base: + * ```kotlin + * val productType = PostTypeDetails(postType = "product", restBase = "products") + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, productType) + * ``` + * + * @property postType The post type slug (e.g., "post", "page", "product"). + * @property restBase The REST API base path for this post type (e.g., "posts", "pages", "products"). + * @property restNamespace The REST API namespace for this post type (e.g., "wp/v2"). Defaults to "wp/v2". + */ +@Parcelize +@Serializable +data class PostTypeDetails( + val postType: String, + val restBase: String, + val restNamespace: String = "wp/v2" +) : Parcelable { + companion object { + /** Standard WordPress post type. */ + val post = PostTypeDetails(postType = "post", restBase = "posts") + + /** Standard WordPress page type. */ + val page = PostTypeDetails(postType = "page", restBase = "pages") + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 507c36351..283d3990d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -285,7 +285,7 @@ class EditorService( return coroutineScope { val activeThemeDeferred = async { prepareActiveTheme() } val settingsOptionsDeferred = async { prepareSettingsOptions() } - val postTypeDataDeferred = async { preparePostType(configuration.postType) } + val postTypeDataDeferred = async { preparePostType(configuration.postType.postType) } val postTypesDataDeferred = async { preparePostTypes() } val postId = configuration.postId diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt index e31247a67..02d4f753c 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt @@ -15,6 +15,7 @@ import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorProgress import org.wordpress.gutenberg.model.LocalEditorAssetManifest +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.TestResources import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod @@ -37,7 +38,7 @@ class EditorAssetsLibraryTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) @@ -46,7 +47,7 @@ class EditorAssetsLibraryTest { val minimalConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(false) .setThemeStyles(false) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index fa9a742a9..40ee19ff6 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -12,6 +12,7 @@ import org.junit.rules.TemporaryFolder import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorSettings +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import org.wordpress.gutenberg.stores.EditorURLCache @@ -42,7 +43,7 @@ class RESTAPIRepositoryTest { siteApiRoot: String = TEST_API_ROOT, siteApiNamespace: Array = arrayOf() ): EditorConfiguration { - return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post") + return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, PostTypeDetails.post) .setPlugins(shouldUsePlugins) .setThemeStyles(shouldUseThemeStyles) .setAuthHeader("Bearer test-token") @@ -290,7 +291,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json", // No trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) @@ -319,7 +320,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json/", // With trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index c0052980b..48e5a8ad8 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -15,7 +15,7 @@ class EditorConfigurationBuilderTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -100,10 +100,10 @@ class EditorConfigurationBuilderTest { @Test fun `setPostType updates postType`() { val config = builder() - .setPostType("page") + .setPostType(PostTypeDetails.page) .build() - assertEquals("page", config.postType) + assertEquals(PostTypeDetails.page, config.postType) } @Test @@ -310,7 +310,7 @@ class EditorConfigurationBuilderTest { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostId(999u) - .setPostType("page") + .setPostType(PostTypeDetails.page) .setPostStatus("draft") .setThemeStyles(true) .setPlugins(true) @@ -387,7 +387,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder preserves nullable values when set`() { val original = builder() .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setEditorSettings("""{"test":true}""") .setEditorAssetsEndpoint("https://example.com/assets") @@ -396,7 +396,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() assertEquals(123u, rebuilt.postId) - assertEquals("post", rebuilt.postType) + assertEquals(PostTypeDetails.post, rebuilt.postType) assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) @@ -480,7 +480,7 @@ class EditorConfigurationBuilderTest { assertTrue(config.enableOfflineMode) assertEquals("https://example.com", config.siteURL) assertEquals("https://example.com/wp-json/", config.siteApiRoot) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) } } @@ -489,7 +489,7 @@ class EditorConfigurationTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -553,10 +553,10 @@ class EditorConfigurationTest { @Test fun `Configurations with different postType are not equal`() { - val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.post) .build() - val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "page") + val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.page) .build() assertNotEquals(config1, config2) @@ -850,11 +850,11 @@ class EditorConfigurationTest { @Test fun `test EditorConfiguration builder sets all properties correctly`() { - val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") + val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", PostTypeDetails.post) .setTitle("Test Title") .setContent("Test Content") .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setThemeStyles(true) .setPlugins(true) @@ -877,7 +877,7 @@ class EditorConfigurationTest { assertEquals("Test Title", config.title) assertEquals("Test Content", config.content) assertEquals(123u, config.postId) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) assertTrue(config.plugins) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt index 6d8dc5f03..2d82c2194 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt @@ -37,7 +37,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = postData, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -51,14 +51,14 @@ class EditorPreloadListTest { @Test fun `initializes with custom post type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), settingsOptionsData = makeResponse() ) - assertEquals("page", preloadList.postType) + assertEquals(PostTypeDetails.page, preloadList.postType) } // MARK: - build() Exact Output Tests @@ -66,7 +66,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for post type`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -81,7 +81,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for page type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(data = """{"slug":"page"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -98,7 +98,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -114,7 +114,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Accept header`() { val headers = EditorHTTPHeaders(mapOf("Accept" to "application/json")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -130,7 +130,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Link header`() { val headers = EditorHTTPHeaders(mapOf("Link" to """; rel="next"""")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -148,7 +148,7 @@ class EditorPreloadListTest { mapOf("Link" to "", "Accept" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -165,7 +165,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = null, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -182,7 +182,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -197,7 +197,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for custom_post_type`() { val preloadList = EditorPreloadList( - postType = "custom_post_type", + postType = PostTypeDetails(postType = "custom_post_type", restBase = "custom_post_type"), postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -214,7 +214,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = false) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -229,7 +229,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -244,7 +244,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) produces pretty-printed JSON`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -283,7 +283,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -304,7 +304,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -326,7 +326,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "Content-Type" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -343,7 +343,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "X-Custom" to "value") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -362,7 +362,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 1, postData = makeResponse(headers = headers), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -379,14 +379,14 @@ class EditorPreloadListTest { fun `two preload lists with same data are equal`() { val response = makeResponse(data = """{"test":true}""") val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -400,14 +400,14 @@ class EditorPreloadListTest { fun `preload lists with different post types are not equal`() { val response = makeResponse() val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -423,7 +423,7 @@ class EditorPreloadListTest { val preloadList1 = EditorPreloadList( postID = 1, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -432,7 +432,7 @@ class EditorPreloadListTest { val preloadList2 = EditorPreloadList( postID = 2, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index a755ca100..aa249247d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -27,7 +27,7 @@ class GBKitGlobalTest { private fun makePreloadList(): EditorPreloadList { return EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), postTypesData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), activeThemeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), @@ -40,7 +40,7 @@ class GBKitGlobalTest { title: String? = null, content: String? = null, siteURL: String = TEST_SITE_URL, - postType: String = "post", + postType: PostTypeDetails = PostTypeDetails.post, shouldUsePlugins: Boolean = true, shouldUseThemeStyles: Boolean = true ): EditorConfiguration { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt index 81afaf959..7e7643505 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -12,6 +12,7 @@ import org.wordpress.gutenberg.EditorHTTPClientDownloadResponse import org.wordpress.gutenberg.EditorHTTPClientProtocol import org.wordpress.gutenberg.EditorHTTPClientResponse import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import java.io.File @@ -35,7 +36,7 @@ class EditorServiceTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml index 3fb3b6665..0cd33fef4 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -2,11 +2,13 @@ - LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) LongMethod:MediaProxyServerActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaProxyServerScreen(onBack: () -> Unit) - LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) + LongMethod:PostsListActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( viewModel: PostsListViewModel, onClose: () -> Unit, onPostSelected: (AnyPostWithEditContext) -> Unit ) + LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) + LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) MaxLineLength:MediaProxyServerActivity.kt$Text("Size", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Throughput", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Time", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) @@ -18,6 +20,8 @@ SwallowedException:SitePreparationViewModel.kt$SitePreparationViewModel$e: java.net.ConnectException ThrowsCount:AuthenticationManager.kt$AuthenticationManager$private fun handleApplicationPasswordsCallback( data: Uri, callback: AuthenticationCallback ) TooGenericExceptionCaught:AuthenticationManager.kt$AuthenticationManager$e: Exception + TooGenericExceptionCaught:EditorActivity.kt$e: Exception + TooGenericExceptionCaught:PostsListActivity.kt$PostsListViewModel$e: Exception TooGenericExceptionCaught:SiteCapabilitiesDiscovery.kt$SiteCapabilitiesDiscovery$e: Exception TooGenericExceptionCaught:SitePreparationActivity.kt$SitePreparationActivity.Companion$e: Exception TooGenericExceptionCaught:SitePreparationViewModel.kt$SitePreparationViewModel$e: Exception diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4eea7d6d5..d5a954927 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,9 @@ + = 0 }?.toULong() + setContent { AppTheme { EditorScreen( configuration = configuration, dependencies = dependencies, + accountId = accountId, coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> @@ -121,6 +135,7 @@ class EditorActivity : ComponentActivity() { fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, + accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} @@ -130,7 +145,12 @@ fun EditorScreen( var hasUndoState by remember { mutableStateOf(false) } var hasRedoState by remember { mutableStateOf(false) } var isCodeEditorEnabled by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } + val saveScope = rememberCoroutineScope() + val context = LocalContext.current + + val canSave = !isSaving && accountId != null && configuration.postId != null BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() @@ -173,8 +193,32 @@ fun EditorScreen( contentDescription = stringResource(R.string.redo) ) } - TextButton(onClick = { }, enabled = false) { - Text(stringResource(R.string.publish)) + TextButton( + onClick = { + val view = gutenbergViewRef ?: return@TextButton + val postId = configuration.postId + if (accountId == null || postId == null) return@TextButton + isSaving = true + saveScope.launch { + try { + val errorMessage = persistPost( + context = context, + view = view, + configuration = configuration, + accountId = accountId, + postId = postId + ) + if (errorMessage != null) { + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() + } + } finally { + isSaving = false + } + } + }, + enabled = canSave && !isModalDialogOpen + ) { + Text(stringResource(R.string.save)) } // Overflow menu button and dropdown in Box for proper anchoring @@ -192,16 +236,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save)) }, - onClick = { }, - enabled = false - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.preview)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(if (isCodeEditorEnabled) R.string.visual_editor else R.string.code_editor)) }, onClick = { @@ -210,16 +244,6 @@ fun EditorScreen( showMenu = false } ) - DropdownMenuItem( - text = { Text(stringResource(R.string.post_settings)) }, - onClick = { }, - enabled = false - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.help)) }, - onClick = { }, - enabled = false - ) } } } @@ -303,3 +327,93 @@ fun EditorScreen( ) } } + +/** + * Suspends until the editor store's save lifecycle completes. + * + * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller + * can sequence post-save work (like persisting content via the REST API). + */ +private suspend fun GutenbergView.savePostAwait(): Boolean = + suspendCancellableCoroutine { continuation -> + savePost { success, _ -> + if (continuation.isActive) continuation.resume(success) + } + } + +/** + * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. + * + * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress + * syncing metadata) settle before the content is read and persisted. A lifecycle + * failure must NOT block the user from saving their work — the warning is logged + * and persistence proceeds anyway. + */ +private suspend fun persistPost( + context: Context, + view: GutenbergView, + configuration: EditorConfiguration, + accountId: ULong, + postId: UInt +): String? { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + val saveSucceeded = view.savePostAwait() + if (saveSucceeded) { + Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") + } else { + Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") + } + + // 2. Persist post content via REST API. + return try { + val titleAndContent = suspendCancellableCoroutine> { cont -> + view.getTitleAndContent( + originalContent = configuration.content, + callback = object : GutenbergView.TitleAndContentCallback { + override fun onResult(title: CharSequence, content: CharSequence) { + if (cont.isActive) cont.resume(title to content) + } + } + ) + } + + val app = context.applicationContext as GutenbergKitApplication + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: error("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (configuration.postType.postType) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(configuration.postType.postType) + } + + val params = PostUpdateParams( + title = titleAndContent.first.toString(), + content = titleAndContent.second.toString(), + meta = null + ) + + val result = client.request { builder -> + builder.posts().update( + postEndpointType = endpointType, + postId = postId.toLong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + Log.i("EditorActivity", "Post $postId persisted via REST API") + null + } + else -> { + Log.e("EditorActivity", "Failed to persist post $postId: $result") + context.getString(R.string.save_failed_generic) + } + } + } catch (e: Exception) { + Log.e("EditorActivity", "Failed to persist post $postId", e) + context.getString(R.string.save_failed_with_reason, e.message ?: "unknown error") + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index 0cc8cea9d..f05d0397d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -3,8 +3,14 @@ package com.example.gutenbergkit import android.app.Application import android.net.ConnectivityManager import android.net.NetworkCapabilities +import java.net.URI import rs.wordpress.api.android.KeystorePasswordTransformer import rs.wordpress.api.kotlin.NetworkAvailabilityProvider +import rs.wordpress.api.kotlin.WpApiClient +import uniffi.wp_api.WpAuthentication +import uniffi.wp_api.WpAuthenticationProvider +import uniffi.wp_api.wpAuthenticationFromUsernameAndPassword +import uniffi.wp_mobile.Account import uniffi.wp_mobile.AccountRepository class GutenbergKitApplication : Application() { @@ -26,4 +32,28 @@ class GutenbergKitApplication : Application() { capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true } } + + /** + * Constructs a [WpApiClient] for the given [account]. Used by the demo app's posts + * list and Save button to fetch/persist posts via the WordPress REST API. + */ + fun createApiClient(account: Account): WpApiClient { + val auth = when (account) { + is Account.SelfHostedSite -> wpAuthenticationFromUsernameAndPassword( + account.username, + account.password + ) + is Account.WpCom -> WpAuthentication.Bearer(token = account.token) + } + val apiRootUrl = when (account) { + is Account.SelfHostedSite -> account.siteApiRoot + is Account.WpCom -> account.siteApiRoot + } + return WpApiClient( + wpOrgSiteApiRootUrl = URI(apiRootUrl).toURL(), + authProvider = WpAuthenticationProvider.staticWithAuth(auth), + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider + ) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt new file mode 100644 index 000000000..a0b85f773 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -0,0 +1,316 @@ +package com.example.gutenbergkit + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.gutenbergkit.ui.theme.AppTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AnyPostWithEditContext +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostStatus + +/** + * Lists posts from a WordPress site so the user can pick one to edit. + * + * Receives an [EditorConfiguration] (already prepared) and an account ID via Intent extras, + * fetches posts via the WordPress REST API using `WpApiClient`, and on selection launches + * [EditorActivity] with the post's title, content, and ID. + */ +class PostsListActivity : ComponentActivity() { + + companion object { + const val EXTRA_ACCOUNT_ID = "account_id" + const val EXTRA_POST_TYPE = "post_type" + + fun createIntent( + context: Context, + accountId: ULong, + postType: PostTypeDetails, + configuration: EditorConfiguration, + dependencies: EditorDependencies? + ): Intent { + return Intent(context, PostsListActivity::class.java).apply { + putExtra(EXTRA_ACCOUNT_ID, accountId.toLong()) + putExtra(EXTRA_POST_TYPE, postType) + putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) + if (dependencies != null) { + val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, filePath) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + val postType = intent.getParcelableExtra(EXTRA_POST_TYPE, PostTypeDetails::class.java) + ?: PostTypeDetails.post + val configuration = intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) + val dependenciesPath = intent.getStringExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH) + + if (accountId == null || configuration == null) { + finish() + return + } + + // Inline factory — simpler than a dedicated `ViewModelProvider.Factory` + // class for a one-shot screen with immutable constructor args. + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + PostsListViewModel(application, accountId, postType) as T + } + val viewModel = ViewModelProvider(this, factory)[PostsListViewModel::class.java] + + setContent { + AppTheme { + PostsListScreen( + viewModel = viewModel, + onClose = { finish() }, + onPostSelected = { post -> + launchEditor(post, configuration, dependenciesPath, accountId) + } + ) + } + } + } + + private fun launchEditor( + post: AnyPostWithEditContext, + baseConfiguration: EditorConfiguration, + dependenciesPath: String?, + accountId: ULong + ) { + val updatedConfig = baseConfiguration.toBuilder() + .setPostId(post.id.toUInt()) + .setTitle(post.title?.raw ?: "") + .setContent(post.content.raw ?: "") + .build() + + val intent = Intent(this, EditorActivity::class.java).apply { + putExtra(MainActivity.EXTRA_CONFIGURATION, updatedConfig) + putExtra(EditorActivity.EXTRA_ACCOUNT_ID, accountId.toLong()) + if (dependenciesPath != null) { + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, dependenciesPath) + } + } + startActivity(intent) + } +} + +data class PostsListUiState( + val posts: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +class PostsListViewModel( + private val application: Application, + private val accountId: ULong, + private val postType: PostTypeDetails +) : ViewModel() { + + private val _uiState = MutableStateFlow(PostsListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadPosts() { + if (_uiState.value.isLoading) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null, posts = emptyList()) } + + try { + val app = application as GutenbergKitApplication + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: error("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (postType.postType) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(postType.postType) + } + + // Single page only — matches the iOS demo, which doesn't paginate either. + val params = PostListParams( + page = 1u, + perPage = 20u, + status = listOf(PostStatus.Any) + ) + val result = client.request { builder -> + builder.posts().listWithEditContext(endpointType, params) + } + when (result) { + is WpRequestResult.Success -> { + _uiState.update { it.copy(posts = result.response.data, isLoading = false) } + } + else -> { + error("Failed to load posts: $result") + } + } + } catch (e: Exception) { + _uiState.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostsListScreen( + viewModel: PostsListViewModel, + onClose: () -> Unit, + onPostSelected: (AnyPostWithEditContext) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadPosts() + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.posts)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when { + uiState.isLoading && uiState.posts.isEmpty() -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + uiState.error != null -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.error_loading_posts), + style = MaterialTheme.typography.titleMedium + ) + Text( + uiState.error ?: "", + style = MaterialTheme.typography.bodyMedium + ) + } + } + uiState.posts.isEmpty() -> { + Text( + stringResource(R.string.no_posts_found), + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(uiState.posts, key = { it.id }) { post -> + PostRow(post = post, onClick = { onPostSelected(post) }) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun PostRow(post: AnyPostWithEditContext, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val title = post.title?.rendered?.ifBlank { "(no title)" } ?: "(no title)" + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val excerpt = post.excerpt?.rendered?.stripHtml().orEmpty() + if (excerpt.isNotBlank()) { + Text( + text = excerpt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun String.stripHtml(): String = + this.replace(Regex("<[^>]+>"), "").trim() diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 27043206b..a66f9326a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -49,12 +49,15 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails class SitePreparationActivity : ComponentActivity() { @@ -140,13 +143,21 @@ class SitePreparationActivity : ComponentActivity() { SitePreparationViewModelFactory(application, configurationItem) )[SitePreparationViewModel::class.java] + val accountId = (configurationItem as? ConfigurationItem.ConfiguredEditor)?.accountId + setContent { AppTheme { SitePreparationScreen( viewModel = viewModel, + accountId = accountId, onClose = { finish() }, onStartEditor = { configuration, dependencies -> launchEditor(configuration, dependencies) + }, + onBrowsePosts = { configuration, dependencies, postType -> + accountId?.let { + launchPostsList(it, postType, configuration, dependencies) + } } ) } @@ -155,7 +166,7 @@ class SitePreparationActivity : ComponentActivity() { private fun launchEditor( configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: EditorDependencies? ) { val intent = Intent(this, EditorActivity::class.java).apply { putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) @@ -168,14 +179,27 @@ class SitePreparationActivity : ComponentActivity() { } startActivity(intent) } + + private fun launchPostsList( + accountId: ULong, + postType: PostTypeDetails, + configuration: EditorConfiguration, + dependencies: EditorDependencies? + ) { + startActivity( + PostsListActivity.createIntent(this, accountId, postType, configuration, dependencies) + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SitePreparationScreen( viewModel: SitePreparationViewModel, + accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit + onStartEditor: (EditorConfiguration, EditorDependencies?) -> Unit, + onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -192,7 +216,7 @@ fun SitePreparationScreen( IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.back) ) } }, @@ -219,6 +243,15 @@ fun SitePreparationScreen( LoadedView( uiState = uiState, viewModel = viewModel, + accountId = accountId, + onBrowsePosts = { + val selectedPostType = uiState.selectedPostType + if (selectedPostType != null) { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, selectedPostType) + } + } + }, modifier = Modifier.padding(innerPadding) ) } @@ -245,6 +278,8 @@ private fun LoadingView(modifier: Modifier = Modifier) { private fun LoadedView( uiState: SitePreparationUiState, viewModel: SitePreparationViewModel, + accountId: ULong?, + onBrowsePosts: () -> Unit, modifier: Modifier = Modifier ) { LazyColumn( @@ -266,8 +301,11 @@ private fun LoadedView( onEnableNativeInserterChange = viewModel::setEnableNativeInserter, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, - postType = uiState.postType, - onPostTypeChange = viewModel::setPostType + postTypes = uiState.postTypes, + selectedPostType = uiState.selectedPostType, + onPostTypeChange = viewModel::setPostType, + showBrowseButton = accountId != null, + onBrowsePosts = onBrowsePosts ) } @@ -347,8 +385,11 @@ private fun FeatureConfigurationCard( onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, - postType: String, - onPostTypeChange: (String) -> Unit + postTypes: List, + selectedPostType: PostTypeDetails?, + onPostTypeChange: (PostTypeDetails) -> Unit, + showBrowseButton: Boolean = false, + onBrowsePosts: () -> Unit = {} ) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { @@ -394,30 +435,48 @@ private fun FeatureConfigurationCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp) ) - Column(modifier = Modifier.selectableGroup()) { - listOf("post" to "Post", "page" to "Page").forEach { (value, label) -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = postType == value, - onClick = { onPostTypeChange(value) }, - role = Role.RadioButton + if (postTypes.isEmpty()) { + Text( + text = "Loading post types…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Column(modifier = Modifier.selectableGroup()) { + postTypes.forEach { postType -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = postType == selectedPostType, + onClick = { onPostTypeChange(postType) }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = postType == selectedPostType, + onClick = null ) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = postType == value, - onClick = null - ) - Text( - text = label, - modifier = Modifier.padding(start = 8.dp) - ) + Text( + text = postType.postType.replaceFirstChar { it.uppercase() }, + modifier = Modifier.padding(start = 8.dp) + ) + } } } } + + if (showBrowseButton) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onBrowsePosts, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.browse)) + } + } } } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index bb1a6c81f..30bfb961e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -13,12 +13,18 @@ import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.services.EditorService +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( val enableNativeInserter: Boolean = true, val enableNetworkLogging: Boolean = false, - val postType: String = "post", + /** All viewable post types fetched from the site, or empty while loading. */ + val postTypes: List = emptyList(), + /** The post type currently selected in the picker. Null until [postTypes] loads. */ + val selectedPostType: PostTypeDetails? = null, val cacheBundleCount: Int? = null, val isLoading: Boolean = false, val error: String? = null, @@ -83,8 +89,8 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableNetworkLogging = enabled) } } - fun setPostType(postType: String) { - _uiState.update { it.copy(postType = postType) } + fun setPostType(postType: PostTypeDetails) { + _uiState.update { it.copy(selectedPostType = postType) } } fun prepareEditor() { @@ -195,10 +201,17 @@ class SitePreparationViewModel( } private fun createBundledConfiguration(): EditorConfiguration { + // Bundled offline editor: only the standard `post` type is meaningful. + _uiState.update { + it.copy( + postTypes = listOf(PostTypeDetails.post), + selectedPostType = PostTypeDetails.post + ) + } return EditorConfiguration.builder( siteURL = "https://example.com", siteApiRoot = "https://example.com", - postType = "post" + postType = PostTypeDetails.post ) .setPlugins(false) .setSiteApiNamespace(arrayOf()) @@ -231,10 +244,23 @@ class SitePreparationViewModel( arrayOf() } + // Fetch the site's post types. Default the picker to `post` when it's + // available (the typical case); otherwise pick the first type in the + // list. Falls back to `PostTypeDetails.post` if the call fails so the + // editor can still launch with a sensible default. + val postTypes = loadPostTypes(config) ?: listOf(PostTypeDetails.post) + val defaultPostType = postTypes.firstOrNull { it.postType == "post" } ?: postTypes.first() + _uiState.update { + it.copy( + postTypes = postTypes, + selectedPostType = defaultPostType + ) + } + return EditorConfiguration.builder( siteURL = config.siteUrl, siteApiRoot = siteApiRoot, - postType = _uiState.value.postType + postType = defaultPostType ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) @@ -250,6 +276,49 @@ class SitePreparationViewModel( .build() } + /** + * Fetches the site's post types from the WordPress REST API and returns the + * subset relevant to the editor picker. + * + * Mirrors the iOS [SitePreparationView.loadPostTypes] filter: + * - always include the standard `post` and `page` types + * - include custom types only when they are `viewable` and have UI visibility + * - exclude all internal types (`Attachment`, `WpBlock`, etc.) + * + * Returns `null` if the post types could not be fetched (e.g. account not + * found, network error). Callers fall back to a sensible default. + */ + private suspend fun loadPostTypes( + config: ConfigurationItem.ConfiguredEditor + ): List? { + val app = getApplication() + val account = app.accountRepository.all().firstOrNull { it.id() == config.accountId } + ?: return null + val client = app.createApiClient(account) + + val result = client.request { builder -> + builder.postTypes().listWithEditContext() + } + if (result !is WpRequestResult.Success) return null + + return result.response.data.postTypes + .filter { (type, details) -> + when (type) { + is WpPostType.Post, is WpPostType.Page -> true + is WpPostType.Custom -> details.viewable && details.visibility.showUi + else -> false + } + } + .map { (_, details) -> + PostTypeDetails( + postType = details.slug, + restBase = details.restBase, + restNamespace = details.restNamespace + ) + } + .sortedBy { it.postType } + } + /** * Extracts the WP.com site ID from a namespace-specific API root URL. * Returns null if the URL is not a WP.com API root. @@ -263,11 +332,12 @@ class SitePreparationViewModel( fun buildConfiguration(): EditorConfiguration? { val baseConfig = _uiState.value.editorConfiguration ?: return null + val selectedPostType = _uiState.value.selectedPostType ?: return null return baseConfig.toBuilder() .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration - .setPostType(_uiState.value.postType) + .setPostType(selectedPostType) .build() } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 16419c4f8..40d2f403c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -35,12 +35,19 @@ Close Undo Redo - PUBLISH More options - Save - Preview + SAVE Visual editor Code editor - Post settings - Help + Failed to save post + Failed to save post: %1$s + + + Browse + + + Posts + Back + No posts found + Error loading posts diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 6d619aadd..f2d53b48b 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -223,6 +223,25 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 0C4F59892BEFF4970028BD96 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, + 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA0000012F00000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ AA0000012F00000000000200 /* Copy OAuth Credentials */ = { isa = PBXShellScriptBuildPhase; @@ -246,25 +265,6 @@ }; /* End PBXShellScriptBuildPhase section */ -/* Begin PBXResourcesBuildPhase section */ - 0C4F59892BEFF4970028BD96 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, - 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - AA0000012F00000000000005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 0C4F59872BEFF4970028BD96 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -561,8 +561,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Automattic/wordpress-rs"; requirement = { - kind = revision; - revision = "alpha-20260313"; + branch = "pr-build/1270"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 3cf171574..88eb12489 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -34,6 +34,26 @@ enum ConfigurationItem: Identifiable, Equatable, Hashable { struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? + let apiClient: WordPressAPI? + + init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) { + self.configuration = configuration + self.dependencies = dependencies + self.apiClient = apiClient + } + + // `apiClient` is intentionally excluded from `==` and `hash(into:)`: + // `WordPressAPI` is not `Hashable`/`Equatable` (it owns native Rust state), + // and two editors with the same configuration but different client + // instances should be treated as equal for navigation/identity purposes. + static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { + lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies + } + + func hash(into hasher: inout Hasher) { + hasher.combine(configuration) + hasher.combine(dependencies) + } } /// Credentials loaded from the wp-env setup script output diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 08650f077..3a741f03f 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -56,7 +56,11 @@ struct GutenbergApp: App { let editor = navigation.editor! NavigationStack { - EditorView(configuration: editor.configuration, dependencies: editor.dependencies) + EditorView( + configuration: editor.configuration, + dependencies: editor.dependencies, + apiClient: editor.apiClient + ) } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6345c8dc5..a8513db0a 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,26 +1,42 @@ import SwiftUI import GutenbergKit +import WordPressAPI struct EditorView: View { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient } var body: some View { _EditorView( configuration: configuration, dependencies: dependencies, + apiClient: apiClient, viewModel: viewModel ) .toolbar { toolbar } + .alert( + "Save failed", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ), + presenting: viewModel.errorMessage + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } } @ToolbarContentBuilder @@ -58,37 +74,29 @@ struct EditorView: View { moreMenu .disabled(viewModel.isModalDialogOpen) } + + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.save() + } label: { + Text("Save") + .fontWeight(.semibold) + } + .disabled(!viewModel.canSave) + .accessibilityLabel("Save") + } } private var moreMenu: some View { Menu { - Section { - Button(action: { - viewModel.isCodeEditorEnabled.toggle() - }, label: { - Label( - viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", - systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - ) - }) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Preview", systemImage: "safari") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Revisions (42)", systemImage: "clock.arrow.circlepath") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Post Settings", systemImage: "gearshape") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Help", systemImage: "questionmark.circle") - }).disabled(true) - } - Section { - Text("Blocks: 4, Words: 8, Characters: 15") - } header: { - - } + Button(action: { + viewModel.isCodeEditorEnabled.toggle() + }, label: { + Label( + viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", + systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" + ) + }) } label: { Image(systemName: "ellipsis") } @@ -99,15 +107,18 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, + apiClient: WordPressAPI? = nil, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient self.viewModel = viewModel } @@ -127,6 +138,13 @@ private struct _EditorView: UIViewControllerRepresentable { } } + viewModel.hasPostID = configuration.postID != nil + + viewModel.saveHandler = { [weak viewController, weak viewModel] in + guard let viewController, let viewModel else { return } + await persistPost(viewController: viewController, viewModel: viewModel) + } + return viewController } @@ -134,6 +152,37 @@ private struct _EditorView: UIViewControllerRepresentable { viewController.isCodeEditorEnabled = viewModel.isCodeEditorEnabled } + /// Triggers the editor store save lifecycle, then persists the post via the REST API. + /// + /// A lifecycle failure must **not** block the user from saving their work — the + /// warning is logged and persistence proceeds anyway. Persist failures surface + /// through `viewModel.errorMessage`, which the parent view binds to an `Alert`. + private func persistPost(viewController: EditorViewController, viewModel: EditorViewModel) async { + do { + try await viewController.savePost() + print("editor.savePost() completed — editor store save lifecycle fired") + } catch { + print("editor.savePost() lifecycle failed; persisting anyway: \(error)") + } + + guard let apiClient, let postID = configuration.postID else { return } + do { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") + } catch { + print("Failed to persist post \(postID): \(error)") + viewModel.errorMessage = "Failed to save post: \(error.localizedDescription)" + } + } + @MainActor class Coordinator: NSObject, EditorViewControllerDelegate { let viewModel: EditorViewModel @@ -145,7 +194,7 @@ private struct _EditorView: UIViewControllerRepresentable { // MARK: - EditorViewControllerDelegate func editorDidLoad(_ viewContoller: EditorViewController) { - // No-op for demo + viewModel.isEditorReady = true } func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { @@ -232,6 +281,15 @@ private final class EditorViewModel { var hasUndo = false var hasRedo = false var isCodeEditorEnabled = false + var isSaving = false + var isEditorReady = false + var errorMessage: String? + + var hasPostID = false + + var canSave: Bool { + isEditorReady && !isSaving && hasPostID + } enum Action { case undo @@ -239,6 +297,16 @@ private final class EditorViewModel { } var perform: (_ action: Action) -> Void = { _ in assertionFailure() } + var saveHandler: () async -> Void = {} + + func save() { + guard canSave else { return } + isSaving = true + Task { + await saveHandler() + isSaving = false + } + } } #Preview { diff --git a/ios/Demo-iOS/Sources/Views/PostsListView.swift b/ios/Demo-iOS/Sources/Views/PostsListView.swift index db0dfb1d1..31ccac38c 100644 --- a/ios/Demo-iOS/Sources/Views/PostsListView.swift +++ b/ios/Demo-iOS/Sources/Views/PostsListView.swift @@ -77,7 +77,8 @@ struct PostsListView: View { let editor = RunnableEditor( configuration: configuration, - dependencies: viewModel.editorDependencies + dependencies: viewModel.editorDependencies, + apiClient: viewModel.client ) navigation.present(editor) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfd11bc82..2bdce030c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -411,6 +411,26 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.redo();") } + /// Triggers the editor store's save lifecycle. + /// + /// This drives the WordPress `core/editor` store through its full save flow, + /// causing `isSavingPost()` to transition `true` → `false`. Plugins that + /// subscribe to this lifecycle (e.g., VideoPress syncing metadata) will fire + /// their side-effect API calls. + /// + /// The actual post content is **not** persisted by this method — the host app + /// is responsible for reading content via ``getTitleAndContent()`` and saving + /// it through its own REST API calls. + /// + /// > Important: If this call throws (for example, because a third-party plugin + /// > subscribed to the lifecycle errors out), hosts should still proceed to + /// > read and persist content. A misbehaving plugin must not block the user + /// > from saving their work — log the lifecycle failure and continue. + public func savePost() async throws { + guard isReady else { throw EditorNotReadyError() } + _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) + } + /// Dismisses the topmost modal dialog or menu in the editor public func dismissTopModal() { guard isReady else { return } diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 4d8301e8d..319f991b4 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -9,9 +9,32 @@ import { renderHook } from '@testing-library/react'; */ import { useHostBridge } from '../use-host-bridge'; -vi.mock( '@wordpress/data' ); +// Hoisted so the `vi.mock` factory below can capture references to the +// same spies the tests assert on. `vi.mock` is hoisted above imports, +// so plain top-level `const`s aren't visible to its factory. +const dispatchMocks = vi.hoisted( () => ( { + savePost: vi.fn(), + removeNotice: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + switchEditorMode: vi.fn(), + editEntityRecord: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), +} ) ); + +vi.mock( '@wordpress/data', () => ( { + useDispatch: vi.fn( () => dispatchMocks ), + useSelect: vi.fn( ( selector ) => { + if ( typeof selector === 'function' ) { + return selector( () => ( {} ) ); + } + return {}; + } ), +} ) ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); +vi.mock( '@wordpress/notices' ); vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/block-editor' ); @@ -47,6 +70,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); + expect( window.editor.savePost ).toBeTypeOf( 'function' ); expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); expect( window.editor.focus ).toBeTypeOf( 'function' ); @@ -70,9 +94,43 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); + expect( window.editor.savePost ).toBeUndefined(); expect( window.editor.switchEditorMode ).toBeUndefined(); expect( window.editor.dismissTopModal ).toBeUndefined(); expect( window.editor.focus ).toBeUndefined(); expect( window.editor.appendTextAtCursor ).toBeUndefined(); } ); + + describe( 'window.editor.savePost', () => { + it( 'removes the editor-save snackbar after a successful save', async () => { + dispatchMocks.savePost.mockResolvedValueOnce( undefined ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + await window.editor.savePost(); + + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); + } ); + + it( 'removes the editor-save snackbar even when the save fails', async () => { + const failure = new Error( 'plugin lifecycle error' ); + dispatchMocks.savePost.mockRejectedValueOnce( failure ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + await expect( window.editor.savePost() ).rejects.toThrow( failure ); + + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); + } ); + } ); } ); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 250933406..fdd531a27 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -5,6 +5,7 @@ import { useEffect, useCallback, useRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; import { parse, serialize, getBlockType } from '@wordpress/blocks'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { insert, create, toHTMLString } from '@wordpress/rich-text'; @@ -18,7 +19,9 @@ window.editor = window.editor || {}; export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); - const { undo, redo, switchEditorMode } = useDispatch( editorStore ); + const { undo, redo, switchEditorMode, savePost } = + useDispatch( editorStore ); + const { removeNotice } = useDispatch( noticesStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -88,6 +91,15 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; + window.editor.savePost = async () => { + try { + return await savePost(); + } finally { + // Native hosts display their own save feedback, disable the default + removeNotice( 'editor-save' ); + } + }; + window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. switchEditorMode( mode ); @@ -186,6 +198,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { delete window.editor.setTitle; delete window.editor.getContent; delete window.editor.getTitleAndContent; + delete window.editor.savePost; delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; @@ -199,6 +212,8 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { markBridgeReady, getEditedPostAttribute, getEditedPostContent, + savePost, + removeNotice, redo, switchEditorMode, undo,