diff --git a/native/swift/Sources/wordpress-api/JetpackSocial+Extensions.swift b/native/swift/Sources/wordpress-api/JetpackSocial+Extensions.swift new file mode 100644 index 000000000..414869c00 --- /dev/null +++ b/native/swift/Sources/wordpress-api/JetpackSocial+Extensions.swift @@ -0,0 +1,97 @@ +import WordPressAPIInternal + +// MARK: - Read extensions + +extension AnyPostWithEditContext { + public var jetpackSocialPublicizeConnections: [JetpackPublicizeConnection]? { + additionalFields.flatMap { + WordPressAPIInternal.jetpackSocialPublicizeConnections(additionalFields: $0) + } + } + + public var jetpackSocialPublicizeMessage: String? { + meta.flatMap { + WordPressAPIInternal.jetpackSocialPublicizeMessage(meta: $0) + } + } + + /// Master toggle for Jetpack Social sharing. Defaults to true on the server. + public var jetpackSocialPublicizeFeatureEnabled: Bool? { + meta.flatMap { + WordPressAPIInternal.jetpackSocialPublicizeFeatureEnabled(meta: $0) + } + } + + /// Whether the post has already been shared to all connections. Server-set, read-only. + public var jetpackSocialPostAlreadyShared: Bool? { + meta.flatMap { + WordPressAPIInternal.jetpackSocialPostAlreadyShared(meta: $0) + } + } +} + +extension AnyPostWithViewContext { + public var jetpackSocialPublicizeConnections: [JetpackPublicizeConnection]? { + additionalFields.flatMap { + WordPressAPIInternal.jetpackSocialPublicizeConnections(additionalFields: $0) + } + } + + public var jetpackSocialPublicizeMessage: String? { + meta.flatMap { + WordPressAPIInternal.jetpackSocialPublicizeMessage(meta: $0) + } + } +} + +// MARK: - Write extensions + +extension PostCreateParams { + public mutating func setJetpackSocialPublicizeConnections( + _ connections: [JetpackPublicizeConnectionUpdate] + ) { + self.additionalFields = jetpackSocialSetPublicizeConnections( + existing: self.additionalFields, + connections: connections + ) + } + + public mutating func setJetpackSocialPublicizeMessage(_ message: String) { + self.meta = jetpackSocialSetPublicizeMessage( + existing: self.meta, + message: message + ) + } + + public mutating func setJetpackSocialPublicizeFeatureEnabled(_ enabled: Bool) { + self.meta = jetpackSocialSetPublicizeFeatureEnabled( + existing: self.meta, + enabled: enabled + ) + } +} + +extension PostUpdateParams { + public mutating func setJetpackSocialPublicizeConnections( + _ connections: [JetpackPublicizeConnectionUpdate] + ) { + self.additionalFields = jetpackSocialSetPublicizeConnections( + existing: self.additionalFields, + connections: connections + ) + } + + public mutating func setJetpackSocialPublicizeMessage(_ message: String) { + self.meta = jetpackSocialSetPublicizeMessage( + existing: self.meta, + message: message + ) + } + + public mutating func setJetpackSocialPublicizeFeatureEnabled(_ enabled: Bool) { + self.meta = jetpackSocialSetPublicizeFeatureEnabled( + existing: self.meta, + enabled: enabled + ) + } +} diff --git a/native/swift/Sources/wordpress-api/WPComApiClient.swift b/native/swift/Sources/wordpress-api/WPComApiClient.swift index 09468c00d..4b93b5f44 100644 --- a/native/swift/Sources/wordpress-api/WPComApiClient.swift +++ b/native/swift/Sources/wordpress-api/WPComApiClient.swift @@ -103,4 +103,12 @@ public final class WPComApiClient: Sendable { public var statsVisits: StatsVisitsRequestExecutor { internalClient.statsVisits() } + + public var publicize: PublicizeRequestExecutor { + internalClient.publicize() + } + + public var meConnections: MeConnectionsRequestExecutor { + internalClient.meConnections() + } } diff --git a/wp_api/src/jetpack/mod.rs b/wp_api/src/jetpack/mod.rs index 9858c8b6b..2f158a1cf 100644 --- a/wp_api/src/jetpack/mod.rs +++ b/wp_api/src/jetpack/mod.rs @@ -3,6 +3,7 @@ use crate::request::endpoint::AsNamespace; pub mod client; pub mod connection; pub mod endpoint; +pub mod social; pub mod videopress; pub(crate) struct JetpackNamespace(); diff --git a/wp_api/src/jetpack/social.rs b/wp_api/src/jetpack/social.rs new file mode 100644 index 000000000..0d36237f9 --- /dev/null +++ b/wp_api/src/jetpack/social.rs @@ -0,0 +1,303 @@ +use crate::AnyJson; +use crate::posts::PostMeta; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// A social media connection from the jetpack_publicize_connections response field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct JetpackPublicizeConnection { + /// Always present in current Jetpack versions. + /// The deprecated `id` field is intentionally not modeled. + pub connection_id: String, + pub display_name: String, + pub service_name: String, + /// Whether this connection is enabled for sharing on this post. + /// Only present in edit context. + pub enabled: Option, + /// Connection health status. None means healthy. + /// Known values: "ok", "broken", "must_reauth". + pub status: Option, +} + +/// Request payload for updating a connection's enabled state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, uniffi::Record)] +pub struct JetpackPublicizeConnectionUpdate { + pub connection_id: String, + pub enabled: bool, +} + +/// Parse publicize connections from a post's additional_fields. +#[uniffi::export] +pub fn jetpack_social_publicize_connections( + additional_fields: &AnyJson, +) -> Option> { + let value = additional_fields.raw.get("jetpack_publicize_connections")?; + serde_json::from_value(value.clone()).ok() +} + +/// Parse the publicize message from post meta. +#[uniffi::export] +pub fn jetpack_social_publicize_message(meta: &PostMeta) -> Option { + meta.raw_value("jetpack_publicize_message") + .and_then(|v| v.as_str().map(String::from)) +} + +/// Parse the publicize feature enabled flag from post meta. +/// This is the master toggle for sharing. Defaults to true on the server. +#[uniffi::export] +pub fn jetpack_social_publicize_feature_enabled(meta: &PostMeta) -> Option { + meta.raw_value("jetpack_publicize_feature_enabled") + .and_then(|v| v.as_bool()) +} + +/// Parse whether the post has already been shared from post meta. +/// This is a read-only server-set value. +#[uniffi::export] +pub fn jetpack_social_post_already_shared(meta: &PostMeta) -> Option { + meta.raw_value("jetpack_social_post_already_shared") + .and_then(|v| v.as_bool()) +} + +/// Insert/update publicize feature enabled flag into a PostMeta. +/// Preserves existing keys. Creates a new PostMeta if existing is None. +#[uniffi::export] +pub fn jetpack_social_set_publicize_feature_enabled( + existing: Option>, + enabled: bool, +) -> Arc { + let base = existing.unwrap_or_else(PostMeta::empty); + let json_value = serde_json::to_string(&enabled).expect("bool serialization should not fail"); + base.upsert("jetpack_publicize_feature_enabled".into(), json_value) +} + +/// Insert/update publicize connection updates into an AnyJson. +/// Preserves existing keys. Creates a new AnyJson if existing is None. +#[uniffi::export] +pub fn jetpack_social_set_publicize_connections( + existing: Option>, + connections: Vec, +) -> Arc { + let base = existing.unwrap_or_else(AnyJson::empty); + let json_value = serde_json::to_string(&connections) + .expect("JetpackPublicizeConnectionUpdate serialization should not fail"); + base.upsert("jetpack_publicize_connections".into(), json_value) +} + +/// Insert/update publicize message into a PostMeta. +/// Preserves existing keys. Creates a new PostMeta if existing is None. +#[uniffi::export] +pub fn jetpack_social_set_publicize_message( + existing: Option>, + message: String, +) -> Arc { + let base = existing.unwrap_or_else(PostMeta::empty); + let json_value = serde_json::to_string(&message).expect("String serialization should not fail"); + base.upsert("jetpack_publicize_message".into(), json_value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_connections_json() -> String { + r#"{"jetpack_publicize_connections": [ + {"connection_id": "123", "display_name": "My Page", "service_name": "facebook", "enabled": true, "status": null}, + {"connection_id": "456", "display_name": "@alice", "service_name": "mastodon", "enabled": false, "status": "broken"} + ]}"# + .to_string() + } + + #[test] + fn test_parse_connections_from_additional_fields() { + let json = AnyJson::from_raw_json(sample_connections_json()); + let connections = jetpack_social_publicize_connections(&json).unwrap(); + assert_eq!(connections.len(), 2); + assert_eq!(connections[0].connection_id, "123"); + assert_eq!(connections[0].display_name, "My Page"); + assert_eq!(connections[0].service_name, "facebook"); + assert_eq!(connections[0].enabled, Some(true)); + assert_eq!(connections[0].status, None); + assert_eq!(connections[1].connection_id, "456"); + assert_eq!(connections[1].enabled, Some(false)); + assert_eq!(connections[1].status, Some("broken".to_string())); + } + + #[test] + fn test_parse_connections_absent() { + let json = AnyJson::from_raw_json(r#"{"other": "data"}"#.to_string()); + assert_eq!(jetpack_social_publicize_connections(&json), None); + } + + #[test] + fn test_parse_connections_enabled_absent() { + let json = AnyJson::from_raw_json( + r#"{"jetpack_publicize_connections": [{"connection_id": "1", "display_name": "X", "service_name": "x"}]}"#.to_string(), + ); + let connections = jetpack_social_publicize_connections(&json).unwrap(); + assert_eq!(connections[0].enabled, None); + } + + #[test] + fn test_parse_message_from_meta() { + let meta: PostMeta = + serde_json::from_str(r#"{"jetpack_publicize_message": "Check this out!"}"#).unwrap(); + assert_eq!( + jetpack_social_publicize_message(&meta), + Some("Check this out!".to_string()) + ); + } + + #[test] + fn test_parse_message_absent() { + let meta: PostMeta = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(jetpack_social_publicize_message(&meta), None); + } + + #[test] + fn test_set_connections_into_empty() { + let updates = vec![ + JetpackPublicizeConnectionUpdate { + connection_id: "123".to_string(), + enabled: true, + }, + JetpackPublicizeConnectionUpdate { + connection_id: "456".to_string(), + enabled: false, + }, + ]; + let result = jetpack_social_set_publicize_connections(None, updates); + // Verify the JSON structure directly — JetpackPublicizeConnectionUpdate has different + // fields than JetpackPublicizeConnection, so we can't round-trip through the read function. + let serialized = serde_json::to_string(result.as_ref()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let arr = value + .get("jetpack_publicize_connections") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("connection_id").unwrap().as_str(), Some("123")); + assert_eq!(arr[0].get("enabled").unwrap().as_bool(), Some(true)); + assert_eq!(arr[1].get("connection_id").unwrap().as_str(), Some("456")); + assert_eq!(arr[1].get("enabled").unwrap().as_bool(), Some(false)); + } + + #[test] + fn test_set_connections_preserves_existing_keys() { + let existing = AnyJson::from_raw_json(r#"{"some_taxonomy": [1, 2, 3]}"#.to_string()); + let updates = vec![JetpackPublicizeConnectionUpdate { + connection_id: "1".to_string(), + enabled: true, + }]; + let result = jetpack_social_set_publicize_connections(Some(existing), updates); + // Verify the connections key was set + let serialized = serde_json::to_string(result.as_ref()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert!(value.get("jetpack_publicize_connections").is_some()); + // Verify existing key is preserved + assert!(value.get("some_taxonomy").is_some()); + } + + #[test] + fn test_set_message_into_empty() { + let result = jetpack_social_set_publicize_message(None, "Hello world".to_string()); + assert_eq!( + jetpack_social_publicize_message(&result), + Some("Hello world".to_string()) + ); + } + + #[test] + fn test_set_message_preserves_existing_meta() { + let existing: PostMeta = + serde_json::from_str(r#"{"footnotes": "[{\"id\":\"1\",\"content\":\"fn\"}]"}"#) + .unwrap(); + let result = + jetpack_social_set_publicize_message(Some(Arc::new(existing)), "msg".to_string()); + assert_eq!( + jetpack_social_publicize_message(&result), + Some("msg".to_string()) + ); + assert!(result.footnotes().is_some()); + } + + #[test] + fn test_parse_feature_enabled_true() { + let meta: PostMeta = + serde_json::from_str(r#"{"jetpack_publicize_feature_enabled": true}"#).unwrap(); + assert_eq!(jetpack_social_publicize_feature_enabled(&meta), Some(true)); + } + + #[test] + fn test_parse_feature_enabled_false() { + let meta: PostMeta = + serde_json::from_str(r#"{"jetpack_publicize_feature_enabled": false}"#).unwrap(); + assert_eq!(jetpack_social_publicize_feature_enabled(&meta), Some(false)); + } + + #[test] + fn test_parse_feature_enabled_absent() { + let meta: PostMeta = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(jetpack_social_publicize_feature_enabled(&meta), None); + } + + #[test] + fn test_parse_post_already_shared_true() { + let meta: PostMeta = + serde_json::from_str(r#"{"jetpack_social_post_already_shared": true}"#).unwrap(); + assert_eq!(jetpack_social_post_already_shared(&meta), Some(true)); + } + + #[test] + fn test_parse_post_already_shared_false() { + let meta: PostMeta = + serde_json::from_str(r#"{"jetpack_social_post_already_shared": false}"#).unwrap(); + assert_eq!(jetpack_social_post_already_shared(&meta), Some(false)); + } + + #[test] + fn test_parse_post_already_shared_absent() { + let meta: PostMeta = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(jetpack_social_post_already_shared(&meta), None); + } + + #[test] + fn test_set_feature_enabled_into_empty() { + let result = jetpack_social_set_publicize_feature_enabled(None, true); + assert_eq!( + jetpack_social_publicize_feature_enabled(&result), + Some(true) + ); + } + + #[test] + fn test_set_feature_enabled_preserves_existing_meta() { + let existing: PostMeta = serde_json::from_str( + r#"{"jetpack_publicize_message": "hello", "jetpack_social_post_already_shared": true}"#, + ) + .unwrap(); + let result = jetpack_social_set_publicize_feature_enabled(Some(Arc::new(existing)), false); + assert_eq!( + jetpack_social_publicize_feature_enabled(&result), + Some(false) + ); + assert_eq!( + jetpack_social_publicize_message(&result), + Some("hello".to_string()) + ); + assert_eq!(jetpack_social_post_already_shared(&result), Some(true)); + } + + #[test] + fn test_round_trip_connections() { + // Verify that a full JetpackPublicizeConnection (from a response) can be serialized + // into AnyJson and parsed back. + let connections_json = sample_connections_json(); + let json = AnyJson::from_raw_json(connections_json); + let serialized = serde_json::to_string(json.as_ref()).unwrap(); + let deserialized = AnyJson::from_raw_json(serialized); + let parsed = jetpack_social_publicize_connections(&deserialized).unwrap(); + assert_eq!(parsed[0].connection_id, "123"); + assert_eq!(parsed[1].service_name, "mastodon"); + } +} diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 7edbe1d79..bac06f486 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -200,6 +200,15 @@ impl AnyJson { self.raw.get(key).map(JsonValue::from) } + /// Creates an AnyJson from a raw JSON string. + /// Returns an empty JSON object if the string is not valid JSON. + #[uniffi::constructor] + pub fn from_raw_json(json: String) -> std::sync::Arc { + let raw = + serde_json::from_str::(&json).unwrap_or(Value::Object(serde_json::Map::new())); + std::sync::Arc::new(AnyJson { raw }) + } + /// Creates an AnyJson from a map of keys to term ID arrays. /// Used to construct the additional_fields for PostCreateParams /// and PostUpdateParams with custom taxonomy term IDs. @@ -217,6 +226,29 @@ impl AnyJson { raw: Value::Object(json_map), }) } + + /// Insert or update a key in the raw JSON object. + /// The `value` parameter is a JSON-encoded string that is parsed before insertion. + /// If `value` is not valid JSON, `Value::Null` is inserted. + pub fn upsert(&self, key: String, value: String) -> std::sync::Arc { + let mut obj = match &self.raw { + Value::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }; + let parsed = serde_json::from_str(&value).unwrap_or(Value::Null); + obj.insert(key, parsed); + std::sync::Arc::new(AnyJson { + raw: Value::Object(obj), + }) + } + + /// Create an empty AnyJson object. + #[uniffi::constructor] + pub fn empty() -> std::sync::Arc { + std::sync::Arc::new(AnyJson { + raw: Value::Object(serde_json::Map::new()), + }) + } } uniffi::custom_newtype!(WpResponseString, Option); @@ -469,4 +501,48 @@ mod tests { )])) ); } + + #[test] + fn test_any_json_empty() { + let json = AnyJson::empty(); + assert_eq!(json.raw, Value::Object(serde_json::Map::new())); + } + + #[test] + fn test_any_json_upsert_into_empty() { + let json = AnyJson::empty(); + let updated = json.upsert("key".to_string(), r#""value""#.to_string()); + assert_eq!( + updated.raw.get("key"), + Some(&Value::String("value".to_string())) + ); + } + + #[test] + fn test_any_json_upsert_preserves_existing_keys() { + let json = AnyJson::from_raw_json(r#"{"existing": 42}"#.to_string()); + let updated = json.upsert("new_key".to_string(), r#""new_value""#.to_string()); + assert_eq!(updated.raw.get("existing"), Some(&Value::Number(42.into()))); + assert_eq!( + updated.raw.get("new_key"), + Some(&Value::String("new_value".to_string())) + ); + } + + #[test] + fn test_any_json_upsert_replaces_existing_key() { + let json = AnyJson::from_raw_json(r#"{"key": "old"}"#.to_string()); + let updated = json.upsert("key".to_string(), r#""new""#.to_string()); + assert_eq!( + updated.raw.get("key"), + Some(&Value::String("new".to_string())) + ); + } + + #[test] + fn test_any_json_upsert_with_invalid_json_inserts_null() { + let json = AnyJson::empty(); + let updated = json.upsert("key".to_string(), "not valid json {".to_string()); + assert_eq!(updated.raw.get("key"), Some(&Value::Null)); + } } diff --git a/wp_api/src/post_revisions.rs b/wp_api/src/post_revisions.rs index 842d55119..62f84fcb0 100644 --- a/wp_api/src/post_revisions.rs +++ b/wp_api/src/post_revisions.rs @@ -9,6 +9,7 @@ use crate::{ wp_content_i64_id, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use wp_contextual::WpContextual; use wp_derive::WpDeriveParamsField; @@ -106,7 +107,7 @@ pub struct SparseAnyPostRevision { pub excerpt: Option, #[WpContext(edit, view)] #[WpContextualOption] - pub meta: Option, + pub meta: Option>, } #[derive(Debug, Serialize, Deserialize, uniffi::Record)] diff --git a/wp_api/src/posts.rs b/wp_api/src/posts.rs index 6ffb1512b..54c6bb684 100644 --- a/wp_api/src/posts.rs +++ b/wp_api/src/posts.rs @@ -10,10 +10,10 @@ use crate::{ wp_content_i64_id, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::sync::Arc; use wp_contextual::WpContextual; -use wp_derive::{WpDeriveParamsField, WpDeserialize}; -use wp_serde_helper::{deserialize_from_string_of_json_array, serialize_as_json_string}; +use wp_derive::WpDeriveParamsField; #[derive( Debug, @@ -239,7 +239,9 @@ pub struct PostCreateParams { #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, // Meta fields. - pub meta: Option, + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option>, // Whether or not the post should be treated as sticky. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -331,7 +333,9 @@ pub struct PostUpdateParams { #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, // Meta fields. - pub meta: Option, + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option>, // Whether or not the post should be treated as sticky. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -425,7 +429,7 @@ pub struct SparseAnyPost { pub format: Option, #[WpContext(edit, view)] #[WpContextualOption] - pub meta: Option, + pub meta: Option>, #[WpContext(edit, view)] #[WpContextualOption] pub sticky: Option, @@ -493,12 +497,55 @@ pub struct SparsePostExcerpt { pub protected: Option, } -#[derive(Debug, PartialEq, Eq, Serialize, WpDeserialize, uniffi::Record)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Object)] +#[uniffi::export(Eq, Hash)] pub struct PostMeta { - #[serde(default)] - #[serde(deserialize_with = "deserialize_from_string_of_json_array")] - #[serde(serialize_with = "serialize_as_json_string")] - pub footnotes: Option>, + #[serde(flatten)] + raw: Value, +} + +impl PostMeta { + /// Access a raw JSON value by key. Crate-internal helper for + /// jetpack/social.rs and other modules that need to read meta fields. + pub(crate) fn raw_value(&self, key: &str) -> Option<&Value> { + self.raw.get(key) + } +} + +#[uniffi::export] +impl PostMeta { + /// Parse footnotes from the raw meta JSON. + /// WordPress stores footnotes as a double-encoded JSON string + /// (the array is JSON-encoded as a string value). + pub fn footnotes(&self) -> Option> { + match self.raw.get("footnotes") { + Some(Value::String(s)) if !s.is_empty() => serde_json::from_str(s).ok(), + _ => None, + } + } + + /// Insert or update a key in the raw JSON object. + /// The `value` parameter is a JSON-encoded string that is parsed before insertion. + /// If `value` is not valid JSON, `Value::Null` is inserted. + pub fn upsert(&self, key: String, value: String) -> Arc { + let mut obj = match &self.raw { + Value::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }; + let parsed = serde_json::from_str(&value).unwrap_or(Value::Null); + obj.insert(key, parsed); + Arc::new(PostMeta { + raw: Value::Object(obj), + }) + } + + /// Create an empty PostMeta. + #[uniffi::constructor] + pub fn empty() -> Arc { + Arc::new(PostMeta { + raw: Value::Object(serde_json::Map::new()), + }) + } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, uniffi::Record)] @@ -756,20 +803,65 @@ mod tests { #[case(r#"{"other": "values"}"#)] fn test_meta_without_footnotes(#[case] json_data: &str) { let meta: PostMeta = serde_json::from_str(json_data).unwrap(); - assert_eq!(meta.footnotes, None); + assert_eq!(meta.footnotes(), None); } #[test] fn test_meta_footnotes() { let json_data = r#"{"footnotes": "[{\"content\":\"some_content\",\"id\":\"some_id\"}]"}"#; let meta: PostMeta = serde_json::from_str(json_data).unwrap(); - let footnotes = meta.footnotes.unwrap(); + let footnotes = meta.footnotes().unwrap(); assert_eq!(footnotes.len(), 1); assert_eq!(footnotes[0].id, "some_id"); assert_eq!(footnotes[0].content, "some_content"); } + #[test] + fn test_meta_preserves_unknown_keys() { + let json_data = r#"{"footnotes": "", "jetpack_publicize_message": "Hello", "other": 42}"#; + let meta: PostMeta = serde_json::from_str(json_data).unwrap(); + let serialized = serde_json::to_string(&meta).unwrap(); + let round_tripped: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + round_tripped.get("jetpack_publicize_message"), + Some(&serde_json::Value::String("Hello".to_string())) + ); + assert_eq!( + round_tripped.get("other"), + Some(&serde_json::Value::Number(42.into())) + ); + } + + #[test] + fn test_meta_empty() { + let meta = PostMeta::empty(); + assert_eq!(meta.footnotes(), None); + } + + #[test] + fn test_meta_upsert_preserves_existing_keys() { + let json_data = r#"{"footnotes": "[{\"content\":\"c\",\"id\":\"i\"}]"}"#; + let meta: PostMeta = serde_json::from_str(json_data).unwrap(); + let updated = meta.upsert("new_key".to_string(), r#""new_value""#.to_string()); + assert!(updated.footnotes().is_some()); + let serialized = serde_json::to_string(updated.as_ref()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + value.get("new_key"), + Some(&serde_json::Value::String("new_value".to_string())) + ); + } + + #[test] + fn test_meta_round_trip_through_serialize() { + let json_data = r#"{"footnotes": "[{\"content\":\"c\",\"id\":\"i\"}]", "extra": true}"#; + let meta: PostMeta = serde_json::from_str(json_data).unwrap(); + let serialized = serde_json::to_string(&meta).unwrap(); + let meta2: PostMeta = serde_json::from_str(&serialized).unwrap(); + assert_eq!(meta.footnotes(), meta2.footnotes()); + } + fn expected_query_pairs_for_post_list_params_with_all_fields() -> String { let after = unit_test_example_date_as_query_value("after"); let modified_after = unit_test_example_date_as_query_value("modified_after"); diff --git a/wp_api/src/wp_com/client.rs b/wp_api/src/wp_com/client.rs index 69014e138..a3f202735 100644 --- a/wp_api/src/wp_com/client.rs +++ b/wp_api/src/wp_com/client.rs @@ -4,8 +4,10 @@ use super::endpoint::{ jetpack_connection_endpoint::{ JetpackConnectionRequestBuilder, JetpackConnectionRequestExecutor, }, + me_connections_endpoint::{MeConnectionsRequestBuilder, MeConnectionsRequestExecutor}, me_endpoint::{MeRequestBuilder, MeRequestExecutor}, oauth2::{Oauth2RequestBuilder, Oauth2RequestExecutor}, + publicize_endpoint::{PublicizeRequestBuilder, PublicizeRequestExecutor}, stats_city_views_endpoint::{StatsCityViewsRequestBuilder, StatsCityViewsRequestExecutor}, stats_clicks_endpoint::{StatsClicksRequestBuilder, StatsClicksRequestExecutor}, stats_country_views_endpoint::{ @@ -70,7 +72,9 @@ pub struct WpComApiRequestBuilder { jetpack_connection: Arc, languages: Arc, me: Arc, + me_connections: Arc, oauth2: Arc, + publicize: Arc, sites: Arc, stats_city_views: Arc, stats_clicks: Arc, @@ -111,7 +115,9 @@ impl WpComApiRequestBuilder { jetpack_connection, languages, me, + me_connections, oauth2, + publicize, sites, stats_city_views, stats_clicks, @@ -163,7 +169,9 @@ pub struct WpComApiClient { jetpack_connection: Arc, languages: Arc, me: Arc, + me_connections: Arc, oauth2: Arc, + publicize: Arc, sites: Arc, stats_city_views: Arc, stats_clicks: Arc, @@ -205,7 +213,9 @@ impl WpComApiClient { jetpack_connection, languages, me, + me_connections, oauth2, + publicize, sites, stats_city_views, stats_clicks, @@ -240,7 +250,9 @@ api_client_generate_endpoint_impl!(WpComApi, followers); api_client_generate_endpoint_impl!(WpComApi, jetpack_connection); api_client_generate_endpoint_impl!(WpComApi, languages); api_client_generate_endpoint_impl!(WpComApi, me); +api_client_generate_endpoint_impl!(WpComApi, me_connections); api_client_generate_endpoint_impl!(WpComApi, oauth2); +api_client_generate_endpoint_impl!(WpComApi, publicize); api_client_generate_endpoint_impl!(WpComApi, sites); api_client_generate_endpoint_impl!(WpComApi, stats_city_views); api_client_generate_endpoint_impl!(WpComApi, stats_clicks); diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 9979b75be..10291f0f9 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -11,8 +11,10 @@ pub mod extensions; pub mod followers_endpoint; pub mod jetpack_connection_endpoint; pub mod languages_endpoint; +pub mod me_connections_endpoint; pub mod me_endpoint; pub mod oauth2; +pub mod publicize_endpoint; pub mod segments_endpoint; pub mod sites_endpoint; pub mod stats_city_views_endpoint; diff --git a/wp_api/src/wp_com/endpoint/me_connections_endpoint.rs b/wp_api/src/wp_com/endpoint/me_connections_endpoint.rs new file mode 100644 index 000000000..00a4b5d0f --- /dev/null +++ b/wp_api/src/wp_com/endpoint/me_connections_endpoint.rs @@ -0,0 +1,25 @@ +use crate::{ + request::endpoint::{AsNamespace, DerivedRequest}, + wp_com::WpComNamespace, + wp_com::me_connections::{ + KeyringConnectionDeleteResponse, KeyringConnectionResponse, KeyringTokenId, + MeConnectionsResponse, + }, +}; +use wp_derive_request_builder::WpDerivedRequest; + +#[derive(WpDerivedRequest)] +enum MeConnectionsRequest { + #[get(url = "/me/connections", output = MeConnectionsResponse)] + List, + #[get(url = "/me/connections/", output = KeyringConnectionResponse)] + Get, + #[delete(url = "/me/connections/", output = KeyringConnectionDeleteResponse)] + Delete, +} + +impl DerivedRequest for MeConnectionsRequest { + fn namespace() -> impl AsNamespace { + WpComNamespace::V2 + } +} diff --git a/wp_api/src/wp_com/endpoint/publicize_endpoint.rs b/wp_api/src/wp_com/endpoint/publicize_endpoint.rs new file mode 100644 index 000000000..6c1408d54 --- /dev/null +++ b/wp_api/src/wp_com/endpoint/publicize_endpoint.rs @@ -0,0 +1,31 @@ +use crate::{ + request::endpoint::{AsNamespace, DerivedRequest}, + wp_com::{ + WpComNamespace, WpComSiteId, + publicize::{ + CreatePublicizeConnectionParams, PublicizeConnectionId, PublicizeConnectionResponse, + PublicizeServiceResponse, UpdatePublicizeConnectionParams, + }, + }, +}; +use wp_derive_request_builder::WpDerivedRequest; + +#[derive(WpDerivedRequest)] +enum PublicizeRequest { + #[get(url = "/sites//publicize/connections", output = Vec)] + ListConnections, + #[get(url = "/sites//publicize/services", output = Vec)] + ListServices, + #[post(url = "/sites//publicize/connections", params = &CreatePublicizeConnectionParams, output = PublicizeConnectionResponse)] + CreateConnection, + #[post(url = "/sites//publicize/connections/", params = &UpdatePublicizeConnectionParams, output = PublicizeConnectionResponse)] + UpdateConnection, + #[delete(url = "/sites//publicize/connections/", output = bool)] + DeleteConnection, +} + +impl DerivedRequest for PublicizeRequest { + fn namespace() -> impl AsNamespace { + WpComNamespace::V2 + } +} diff --git a/wp_api/src/wp_com/me_connections.rs b/wp_api/src/wp_com/me_connections.rs new file mode 100644 index 000000000..daeecf9f4 --- /dev/null +++ b/wp_api/src/wp_com/me_connections.rs @@ -0,0 +1,161 @@ +use crate::impl_as_query_value_for_new_type; +use serde::{Deserialize, Serialize}; + +impl_as_query_value_for_new_type!(KeyringTokenId); +uniffi::custom_newtype!(KeyringTokenId, i64); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyringTokenId(pub i64); + +impl std::str::FromStr for KeyringTokenId { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { + s.parse().map(Self) + } +} + +impl std::fmt::Display for KeyringTokenId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Envelope for the list endpoint: `{ "connections": [...] }`. +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct MeConnectionsResponse { + pub connections: Vec, +} + +/// A keyring connection (OAuth token for a third-party service). +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct KeyringConnectionResponse { + #[serde(rename = "ID")] + pub id: i64, + #[serde(rename = "user_ID")] + pub user_id: i64, + pub service: String, + pub label: Option, + pub issued: Option, + pub expires: Option, + #[serde(rename = "external_ID")] + pub external_id: String, + pub external_name: String, + pub external_display: String, + pub external_profile_picture: Option, + pub status: String, + #[serde(rename = "refresh_URL")] + pub refresh_url: String, + pub additional_external_users: Vec, +} + +/// An alternative account available on the same keyring connection. +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct KeyringExternalUser { + #[serde(rename = "external_ID")] + pub external_id: String, + pub external_name: String, + pub external_profile_picture: Option, + pub external_description: Option, + pub external_category: Option, +} + +/// Response from `DELETE /me/connections/{token_id}`. +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct KeyringConnectionDeleteResponse { + #[serde(rename = "ID")] + pub id: i64, + pub deleted: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_list_response() { + let json = include_str!("../../tests/wpcom/me_connections/list.json"); + let response: MeConnectionsResponse = + serde_json::from_str(json).expect("Failed to deserialize list response"); + assert_eq!(response.connections.len(), 2); + + let mastodon = &response.connections[0]; + assert_eq!(mastodon.id, 98765); + assert_eq!(mastodon.user_id, 12345); + assert_eq!(mastodon.service, "mastodon"); + assert_eq!(mastodon.label, Some("Mastodon".to_string())); + assert_eq!(mastodon.status, "ok"); + assert!(mastodon.additional_external_users.is_empty()); + assert_eq!(mastodon.expires, None); + } + + #[test] + fn test_deserialize_list_response_nullable_fields() { + let json = include_str!("../../tests/wpcom/me_connections/list.json"); + let response: MeConnectionsResponse = + serde_json::from_str(json).expect("Failed to deserialize"); + + let facebook = &response.connections[1]; + assert_eq!(facebook.label, None); + assert_eq!(facebook.issued, None); + assert_eq!(facebook.external_profile_picture, None); + assert_eq!(facebook.expires, Some("2026-03-25 00:00:00".to_string())); + } + + #[test] + fn test_deserialize_additional_external_users() { + let json = include_str!("../../tests/wpcom/me_connections/list.json"); + let response: MeConnectionsResponse = + serde_json::from_str(json).expect("Failed to deserialize"); + + let facebook = &response.connections[1]; + assert_eq!(facebook.additional_external_users.len(), 2); + + let page = &facebook.additional_external_users[0]; + assert_eq!(page.external_id, "200012345678"); + assert_eq!(page.external_name, "My Page"); + assert_eq!( + page.external_profile_picture, + Some("https://example.com/page-pic.jpg".to_string()) + ); + assert_eq!( + page.external_description, + Some("A test Facebook page".to_string()) + ); + assert_eq!(page.external_category, Some("Internet company".to_string())); + + let page2 = &facebook.additional_external_users[1]; + assert_eq!(page2.external_profile_picture, None); + assert_eq!(page2.external_description, None); + assert_eq!(page2.external_category, None); + } + + #[test] + fn test_deserialize_single_connection() { + let json = include_str!("../../tests/wpcom/me_connections/single.json"); + let connection: KeyringConnectionResponse = + serde_json::from_str(json).expect("Failed to deserialize single connection"); + assert_eq!(connection.id, 98765); + assert_eq!(connection.service, "mastodon"); + assert_eq!( + connection.refresh_url, + "https://public-api.wordpress.com/connect/?action=request&service=mastodon" + ); + } + + #[test] + fn test_deserialize_delete_response() { + let json = r#"{"ID": 98765, "deleted": true}"#; + let response: KeyringConnectionDeleteResponse = + serde_json::from_str(json).expect("Failed to deserialize delete response"); + assert_eq!(response.id, 98765); + assert!(response.deleted); + } + + #[test] + fn test_deserialize_empty_connections_list() { + let json = r#"{"connections": []}"#; + let response: MeConnectionsResponse = + serde_json::from_str(json).expect("Failed to deserialize empty list"); + assert!(response.connections.is_empty()); + } +} diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 846329a27..351ec2fc8 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -10,7 +10,9 @@ pub mod followers; pub mod jetpack_connection; pub mod language; pub mod me; +pub mod me_connections; pub mod oauth2; +pub mod publicize; pub mod segments; pub mod sites; pub mod stats_city_views; diff --git a/wp_api/src/wp_com/publicize.rs b/wp_api/src/wp_com/publicize.rs new file mode 100644 index 000000000..a0471696a --- /dev/null +++ b/wp_api/src/wp_com/publicize.rs @@ -0,0 +1,148 @@ +use crate::impl_as_query_value_for_new_type; +use serde::{Deserialize, Serialize}; + +impl_as_query_value_for_new_type!(PublicizeConnectionId); +uniffi::custom_newtype!(PublicizeConnectionId, String); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PublicizeConnectionId(pub String); + +impl std::fmt::Display for PublicizeConnectionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A social media connection from the site-level publicize connections endpoint. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PublicizeConnectionResponse { + pub connection_id: String, + pub display_name: String, + pub external_handle: String, + pub external_id: String, + pub profile_link: String, + pub profile_picture: String, + pub service_label: String, + pub service_name: String, + pub shared: bool, + pub wpcom_user_id: i64, + pub id: String, + pub username: String, + pub profile_display_name: String, + pub global: bool, + pub status: Option, +} + +/// An available social media service from the publicize services endpoint. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PublicizeServiceResponse { + pub id: String, + pub description: String, + pub label: String, + pub status: String, + pub supports: PublicizeServiceSupports, + pub url: String, +} + +/// Capabilities of a publicize service. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PublicizeServiceSupports { + pub additional_users: bool, + pub additional_users_only: bool, +} + +/// Parameters for creating a new publicize connection. +#[derive(Debug, Serialize, uniffi::Record)] +pub struct CreatePublicizeConnectionParams { + #[serde(rename = "keyring_connection_ID")] + pub keyring_connection_id: i64, + #[serde(rename = "external_user_ID")] + #[serde(skip_serializing_if = "Option::is_none")] + pub external_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shared: Option, +} + +/// Parameters for updating an existing publicize connection. +#[derive(Debug, Serialize, uniffi::Record)] +pub struct UpdatePublicizeConnectionParams { + #[serde(rename = "external_user_ID")] + #[serde(skip_serializing_if = "Option::is_none")] + pub external_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shared: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_connections_response() { + let json = include_str!("../../tests/wpcom/publicize/connections.json"); + let connections: Vec = + serde_json::from_str(json).expect("Failed to deserialize connections"); + assert_eq!(connections.len(), 2); + assert_eq!(connections[0].connection_id, "25868837"); + assert_eq!(connections[0].display_name, "@tonya8c@mastodon.social"); + assert_eq!(connections[0].service_name, "mastodon"); + assert!(connections[0].shared); + assert_eq!(connections[0].status, None); + assert_eq!(connections[1].connection_id, "25868865"); + assert_eq!(connections[1].service_name, "bluesky"); + assert_eq!(connections[1].status, Some("broken".to_string())); + } + + #[test] + fn test_deserialize_services_response() { + let json = include_str!("../../tests/wpcom/publicize/services.json"); + let services: Vec = + serde_json::from_str(json).expect("Failed to deserialize services"); + assert_eq!(services.len(), 3); + assert_eq!(services[0].id, "bluesky"); + assert_eq!(services[0].label, "Bluesky"); + assert!(!services[0].supports.additional_users); + assert_eq!(services[1].id, "facebook"); + assert!(services[1].supports.additional_users_only); + } + + #[test] + fn test_deserialize_empty_connections() { + let connections: Vec = + serde_json::from_str("[]").expect("Failed to deserialize empty connections"); + assert!(connections.is_empty()); + } + + #[test] + fn test_deserialize_empty_services() { + let services: Vec = + serde_json::from_str("[]").expect("Failed to deserialize empty services"); + assert!(services.is_empty()); + } + + #[test] + fn test_serialize_create_connection_params() { + let params = CreatePublicizeConnectionParams { + keyring_connection_id: 12345, + external_user_id: Some("67890".to_string()), + shared: Some(true), + }; + let json = serde_json::to_value(¶ms).expect("Failed to serialize"); + assert_eq!(json["keyring_connection_ID"], 12345); + assert_eq!(json["external_user_ID"], "67890"); + assert_eq!(json["shared"], true); + } + + #[test] + fn test_serialize_create_connection_params_minimal() { + let params = CreatePublicizeConnectionParams { + keyring_connection_id: 12345, + external_user_id: None, + shared: None, + }; + let json = serde_json::to_value(¶ms).expect("Failed to serialize"); + assert_eq!(json["keyring_connection_ID"], 12345); + assert!(json.get("external_user_ID").is_none()); + assert!(json.get("shared").is_none()); + } +} diff --git a/wp_api/tests/wpcom/me_connections/list.json b/wp_api/tests/wpcom/me_connections/list.json new file mode 100644 index 000000000..4df3cd7a4 --- /dev/null +++ b/wp_api/tests/wpcom/me_connections/list.json @@ -0,0 +1,55 @@ +{ + "connections": [ + { + "ID": 98765, + "user_ID": 12345, + "type": "publicize", + "service": "mastodon", + "label": "Mastodon", + "issued": "2025-01-15 10:30:00", + "expires": null, + "external_ID": "116283451478301581", + "external_name": "@testuser@mastodon.social", + "external_display": "@testuser@mastodon.social", + "external_profile_picture": "https://mastodon.social/avatars/original/missing.png", + "status": "ok", + "refresh_URL": "https://public-api.wordpress.com/connect/?action=request&service=mastodon", + "sites": [234567], + "additional_external_users": [] + }, + { + "ID": 98766, + "user_ID": 12345, + "type": "publicize", + "service": "facebook", + "label": null, + "issued": null, + "expires": "2026-03-25 00:00:00", + "external_ID": "100012345678", + "external_name": "Test User", + "external_display": "Test User", + "external_profile_picture": null, + "status": "ok", + "refresh_URL": "https://public-api.wordpress.com/connect/?action=request&service=facebook", + "sites": [], + "additional_external_users": [ + { + "external_ID": "200012345678", + "external_name": "My Page", + "external_profile_picture": "https://example.com/page-pic.jpg", + "external_description": "A test Facebook page", + "external_category": "Internet company", + "external_meta": {} + }, + { + "external_ID": "300012345678", + "external_name": "Another Page", + "external_profile_picture": null, + "external_description": null, + "external_category": null, + "external_meta": {} + } + ] + } + ] +} diff --git a/wp_api/tests/wpcom/me_connections/single.json b/wp_api/tests/wpcom/me_connections/single.json new file mode 100644 index 000000000..75e4c3987 --- /dev/null +++ b/wp_api/tests/wpcom/me_connections/single.json @@ -0,0 +1,17 @@ +{ + "ID": 98765, + "user_ID": 12345, + "type": "publicize", + "service": "mastodon", + "label": "Mastodon", + "issued": "2025-01-15 10:30:00", + "expires": null, + "external_ID": "116283451478301581", + "external_name": "@testuser@mastodon.social", + "external_display": "@testuser@mastodon.social", + "external_profile_picture": "https://mastodon.social/avatars/original/missing.png", + "status": "ok", + "refresh_URL": "https://public-api.wordpress.com/connect/?action=request&service=mastodon", + "sites": [234567], + "additional_external_users": [] +} diff --git a/wp_api/tests/wpcom/publicize/connections.json b/wp_api/tests/wpcom/publicize/connections.json new file mode 100644 index 000000000..b0825c46f --- /dev/null +++ b/wp_api/tests/wpcom/publicize/connections.json @@ -0,0 +1,36 @@ +[ + { + "connection_id": "25868837", + "display_name": "@tonya8c@mastodon.social", + "external_handle": "@tonya8c@mastodon.social", + "external_id": "116283451478301581", + "profile_link": "https://mastodon.social/@tonya8c", + "profile_picture": "https://mastodon.social/avatars/original/missing.png", + "service_label": "Mastodon", + "service_name": "mastodon", + "shared": true, + "wpcom_user_id": 0, + "id": "32006110", + "username": "@tonya8c@mastodon.social", + "profile_display_name": "", + "global": true, + "status": null + }, + { + "connection_id": "25868865", + "display_name": "tonya8c.bsky.social", + "external_handle": "tonya8c.bsky.social", + "external_id": "did:plc:6jo6m3rdhritnb3qhxbrdshq", + "profile_link": "https://bsky.app/profile/did:plc:6jo6m3rdhritnb3qhxbrdshq", + "profile_picture": "https://cdn.bsky.app/img/avatar/plain/did:plc:6jo6m3rdhritnb3qhxbrdshq/bafkreiebaps2pr4v2qhfbjfywi3f2wcatf3d6mtu3fsobye22vos7auldi", + "service_label": "Bluesky", + "service_name": "bluesky", + "shared": true, + "wpcom_user_id": 0, + "id": "32006200", + "username": "tonya8c.bsky.social", + "profile_display_name": "", + "global": true, + "status": "broken" + } +] diff --git a/wp_api/tests/wpcom/publicize/services.json b/wp_api/tests/wpcom/publicize/services.json new file mode 100644 index 000000000..a3ce6b238 --- /dev/null +++ b/wp_api/tests/wpcom/publicize/services.json @@ -0,0 +1,35 @@ +[ + { + "id": "bluesky", + "description": "Publish your posts to Bluesky.", + "label": "Bluesky", + "status": "ok", + "supports": { + "additional_users": false, + "additional_users_only": false + }, + "url": "https://public-api.wordpress.com/connect/?action=request&service=bluesky" + }, + { + "id": "facebook", + "description": "Publish your posts to your Facebook timeline or page.", + "label": "Facebook", + "status": "ok", + "supports": { + "additional_users": true, + "additional_users_only": true + }, + "url": "https://public-api.wordpress.com/connect/?action=request&service=facebook" + }, + { + "id": "mastodon", + "description": "Publish your posts to your Mastodon instance.", + "label": "Mastodon", + "status": "ok", + "supports": { + "additional_users": false, + "additional_users_only": false + }, + "url": "https://public-api.wordpress.com/connect/?action=request&service=mastodon" + } +] diff --git a/wp_api_integration_tests/tests/test_pages_mut.rs b/wp_api_integration_tests/tests/test_pages_mut.rs index 02db62c90..6dc1dc8c3 100644 --- a/wp_api_integration_tests/tests/test_pages_mut.rs +++ b/wp_api_integration_tests/tests/test_pages_mut.rs @@ -1,7 +1,8 @@ use macro_helper::{generate_update_page_status_test, generate_update_test}; +use std::sync::Arc; use wp_api::posts::{ - AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostFootnote, PostMeta, - PostPingStatus, PostStatus, PostUpdateParams, + AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostMeta, PostPingStatus, + PostStatus, PostUpdateParams, }; use wp_api::request::endpoint::posts_endpoint::PostEndpointType; use wp_api_integration_tests::{PAGE_TEMPLATE_WITH_SIDEBAR, prelude::*}; @@ -32,17 +33,17 @@ async fn create_page_with_title_and_meta() { test_create_page( &PostCreateParams { title: Some("foo".to_string()), - meta: Some(PostMeta { - footnotes: Some(vec![PostFootnote { - id: "bar".to_string(), - content: "baz".to_string(), - }]), - }), + meta: Some(Arc::new( + serde_json::from_str::( + r#"{"footnotes": "[{\"id\":\"bar\",\"content\":\"baz\"}]"}"#, + ) + .unwrap(), + )), ..Default::default() }, |created_page, page_from_wp_cli| { let meta = created_page.meta.unwrap(); - let footnotes = meta.footnotes.unwrap(); + let footnotes = meta.footnotes().unwrap(); let footnote = footnotes.first().unwrap(); assert_eq!( created_page.title.and_then(|t| t.raw), @@ -357,15 +358,15 @@ generate_update_test!( generate_update_test!( update_meta_to_add_footnote, meta, - PostMeta { - footnotes: Some(vec![PostFootnote { - id: "foo".to_string(), - content: "bar".to_string() - }]) - }, + Arc::new( + serde_json::from_str::( + r#"{"footnotes": "[{\"id\":\"foo\",\"content\":\"bar\"}]"}"# + ) + .unwrap() + ), |updated_page, _| { let meta = updated_page.meta.unwrap(); - let footnotes = meta.footnotes.unwrap(); + let footnotes = meta.footnotes().unwrap(); let footnote = footnotes.first().unwrap(); assert_eq!(footnote.id, "foo"); assert_eq!(footnote.content, "bar"); diff --git a/wp_api_integration_tests/tests/test_posts_mut.rs b/wp_api_integration_tests/tests/test_posts_mut.rs index bf3d8e4eb..a419b0b28 100644 --- a/wp_api_integration_tests/tests/test_posts_mut.rs +++ b/wp_api_integration_tests/tests/test_posts_mut.rs @@ -2,10 +2,11 @@ use macro_helper::{ generate_update_post_format_test, generate_update_post_status_test, generate_update_test, }; use std::collections::HashMap; +use std::sync::Arc; use wp_api::AnyJson; use wp_api::posts::{ - AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostFootnote, PostFormat, - PostListParams, PostMeta, PostPingStatus, PostStatus, PostUpdateParams, + AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostFormat, PostListParams, + PostMeta, PostPingStatus, PostStatus, PostUpdateParams, }; use wp_api::request::endpoint::posts_endpoint::PostEndpointType; use wp_api::terms::TermId; @@ -37,17 +38,17 @@ async fn create_post_with_title_and_meta() { test_create_post( &PostCreateParams { title: Some("foo".to_string()), - meta: Some(PostMeta { - footnotes: Some(vec![PostFootnote { - id: "bar".to_string(), - content: "baz".to_string(), - }]), - }), + meta: Some(Arc::new( + serde_json::from_str::( + r#"{"footnotes": "[{\"id\":\"bar\",\"content\":\"baz\"}]"}"#, + ) + .unwrap(), + )), ..Default::default() }, |created_post, post_from_wp_cli| { let meta = created_post.meta.unwrap(); - let footnotes = meta.footnotes.unwrap(); + let footnotes = meta.footnotes().unwrap(); let footnote = footnotes.first().unwrap(); assert_eq!( created_post.title.and_then(|t| t.raw), @@ -328,15 +329,15 @@ generate_update_test!( generate_update_test!( update_meta_to_add_footnote, meta, - PostMeta { - footnotes: Some(vec![PostFootnote { - id: "foo".to_string(), - content: "bar".to_string() - }]) - }, + Arc::new( + serde_json::from_str::( + r#"{"footnotes": "[{\"id\":\"foo\",\"content\":\"bar\"}]"}"# + ) + .unwrap() + ), |updated_post, _| { let meta = updated_post.meta.unwrap(); - let footnotes = meta.footnotes.unwrap(); + let footnotes = meta.footnotes().unwrap(); let footnote = footnotes.first().unwrap(); assert_eq!(footnote.id, "foo"); assert_eq!(footnote.content, "bar"); diff --git a/wp_mobile_cache/migrations/0013-invalidate-posts-for-full-meta.sql b/wp_mobile_cache/migrations/0013-invalidate-posts-for-full-meta.sql new file mode 100644 index 000000000..b55dc3113 --- /dev/null +++ b/wp_mobile_cache/migrations/0013-invalidate-posts-for-full-meta.sql @@ -0,0 +1,5 @@ +-- Existing cached posts have PostMeta that was serialized with only the +-- footnotes field. Other meta keys (e.g., jetpack_publicize_message) were +-- silently dropped. Now that PostMeta preserves all keys, mark cached +-- posts as Stale so they are re-fetched with complete meta data. +UPDATE entity_state SET state = 3 WHERE entity_type = 0 AND state = 2; diff --git a/wp_mobile_cache/src/repository/posts.rs b/wp_mobile_cache/src/repository/posts.rs index 66f1797ee..6a4db1741 100644 --- a/wp_mobile_cache/src/repository/posts.rs +++ b/wp_mobile_cache/src/repository/posts.rs @@ -26,8 +26,8 @@ use wp_api::{ posts::{ AnyPostWithEditContext, AnyPostWithEmbedContext, AnyPostWithViewContext, PostContentWithEditContext, PostContentWithViewContext, PostGuidWithEditContext, - PostGuidWithViewContext, PostId, PostTitleWithEditContext, PostTitleWithEmbedContext, - PostTitleWithViewContext, SparsePostExcerpt, + PostGuidWithViewContext, PostId, PostMeta, PostTitleWithEditContext, + PostTitleWithEmbedContext, PostTitleWithViewContext, SparsePostExcerpt, }, prelude::WpGmtDateTime, taxonomies::TaxonomyType, @@ -507,7 +507,7 @@ impl PostContext for EditContext { comment_status: parse_optional_enum(row, CommentStatus)?, ping_status: parse_optional_enum(row, PingStatus)?, format: parse_optional_enum(row, Format)?, - meta: deserialize_json_value(row.get_column(Meta)?)?, + meta: deserialize_json_value::(row.get_column(Meta)?)?.map(Arc::new), sticky: integer_to_bool(row.get_column(Sticky)?), template: row.get_column(Template)?, categories: if categories.is_empty() { @@ -596,7 +596,7 @@ impl PostContext for ViewContext { comment_status: parse_optional_enum(row, CommentStatus)?, ping_status: parse_optional_enum(row, PingStatus)?, format: parse_optional_enum(row, Format)?, - meta: deserialize_json_value(row.get_column(Meta)?)?, + meta: deserialize_json_value::(row.get_column(Meta)?)?.map(Arc::new), sticky: integer_to_bool(row.get_column(Sticky)?), template: row.get_column(Template)?, categories: if categories.is_empty() { diff --git a/wp_mobile_cache/src/test_fixtures/posts.rs b/wp_mobile_cache/src/test_fixtures/posts.rs index b2fd216d4..f41456311 100644 --- a/wp_mobile_cache/src/test_fixtures/posts.rs +++ b/wp_mobile_cache/src/test_fixtures/posts.rs @@ -1,9 +1,10 @@ +use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use wp_api::{ media::MediaId, posts::{ - AnyPostWithEditContext, PostContentWithEditContext, PostFootnote, PostGuidWithEditContext, - PostId, PostMeta, PostStatus, PostTitleWithEditContext, SparsePostExcerpt, + AnyPostWithEditContext, PostContentWithEditContext, PostGuidWithEditContext, PostId, + PostMeta, PostStatus, PostTitleWithEditContext, SparsePostExcerpt, }, terms::TermId, users::UserId, @@ -257,18 +258,12 @@ fn create_full_post() -> AnyPostWithEditContext { comment_status: Some(wp_api::posts::PostCommentStatus::Open), ping_status: Some(wp_api::posts::PostPingStatus::Closed), format: Some(wp_api::posts::PostFormat::Standard), - meta: Some(PostMeta { - footnotes: Some(vec![ - PostFootnote { - id: "fn1".to_string(), - content: "Footnote 1".to_string(), - }, - PostFootnote { - id: "fn2".to_string(), - content: "Footnote 2".to_string(), - }, - ]), - }), + meta: Some(Arc::new( + serde_json::from_str::( + r#"{"footnotes": "[{\"id\":\"fn1\",\"content\":\"Footnote 1\"},{\"id\":\"fn2\",\"content\":\"Footnote 2\"}]"}"#, + ) + .unwrap(), + )), sticky: Some(true), template: "custom-template.php".to_string(), categories: Some(vec![TermId(1), TermId(2), TermId(3)]),