Skip to content

Commit f95cdd9

Browse files
authored
Merge branch 'trunk' into fix/crash-on-launch
2 parents 8aea6df + 77eaab9 commit f95cdd9

File tree

11 files changed

+713
-11
lines changed

11 files changed

+713
-11
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
26.5
22
-----
3+
* [*] Add "Access" section to "Post Settings" [#24942]
34
* [*] Fix rare crash on launch when new editor is enabled [#24944]
45

56
26.4
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import Foundation
2+
import WordPressKit
3+
import WordPressShared
4+
5+
/// A convenience struct that provides CRUD operations on post metadata.
6+
///
7+
/// ## WordPress Metadata Overview
8+
///
9+
/// WordPress stores custom metadata as key-value pairs associated with posts.
10+
/// Each metadata item contains a string key, a value (which can be any
11+
/// JSON-serializable type), and an optional ID for database tracking.
12+
///
13+
/// ## Expected Format
14+
///
15+
/// Metadata is stored as a JSON array of dictionaries, where each dictionary represents one
16+
/// metadata item:
17+
///
18+
/// ```json
19+
/// [
20+
/// {
21+
/// "key": "_jetpack_newsletter_access",
22+
/// "value": "subscribers",
23+
/// "id": "123"
24+
/// },
25+
/// {
26+
/// "key": "custom_field",
27+
/// "value": "some value"
28+
/// }
29+
/// ]
30+
/// ```
31+
public struct PostMetadata {
32+
public struct Key: ExpressibleByStringLiteral, Hashable {
33+
public let rawValue: String
34+
35+
public init(rawValue: String) {
36+
self.rawValue = rawValue
37+
}
38+
39+
public init(stringLiteral value: String) {
40+
self.rawValue = value
41+
}
42+
}
43+
44+
enum Error: Swift.Error {
45+
case invalidData
46+
}
47+
48+
// Raw JSON dictionaries, keyed by metadata key
49+
private var items: [Key: [String: Any]] = [:]
50+
51+
/// Returns all metadata as a dictionary (alias for allItems)
52+
public var values: [[String: Any]] {
53+
Array(items.values)
54+
}
55+
56+
/// Initialized metadata with the given post.
57+
public init(_ post: AbstractPost) {
58+
if let data = post.rawMetadata {
59+
do {
60+
let metadata = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] ?? []
61+
self = PostMetadata(metadata: metadata)
62+
} catch {
63+
wpAssertionFailure("Failed to decode metadata JSON", userInfo: ["error": error.localizedDescription])
64+
self = PostMetadata()
65+
}
66+
} else {
67+
self = PostMetadata()
68+
}
69+
}
70+
71+
/// Initialize with raw metadata Data (non-throwing version for backward compatibility)
72+
/// If the data is invalid, creates an empty PostMetadata
73+
///
74+
/// - Parameter data: The JSON data containing metadata array
75+
public init(data: Data) throws {
76+
let metadata = try JSONSerialization.jsonObject(with: data)
77+
guard let dictionary = metadata as? [[String: Any]] else {
78+
throw Error.invalidData
79+
}
80+
self = PostMetadata(metadata: dictionary)
81+
}
82+
83+
/// Initialize with raw metadata array (same format as JSON data)
84+
/// - Parameter metadata: Array of metadata dictionaries with "key", "value", and optional "id"
85+
public init(metadata: [[String: Any]] = []) {
86+
for item in metadata {
87+
if let key = item["key"] as? String {
88+
self.items[Key(rawValue: key)] = item
89+
}
90+
}
91+
}
92+
93+
// MARK: - Encoding
94+
95+
/// Encodes the metadata back to Data for storage in rawMetadata
96+
/// - Returns: JSON Data representation of the metadata, or nil if empty
97+
public func encode() throws -> Data {
98+
do {
99+
return try JSONSerialization.data(withJSONObject: Array(items.values), options: [])
100+
} catch {
101+
wpAssertionFailure("Failed to encode metadata to JSON", userInfo: ["error": error.localizedDescription])
102+
throw error
103+
}
104+
}
105+
106+
// MARK: - CRUD
107+
108+
/// Retrieves a metadata value by key with generic type casting
109+
/// - Parameters:
110+
/// - expectedType: The expected type of the value
111+
/// - key: The metadata key to search for
112+
/// - Returns: The value cast to the specified type if found and compatible, nil otherwise
113+
public func getValue<T>(_ expectedType: T.Type, forKey key: Key) -> T? {
114+
guard let dict = items[key], let value = dict["value"] else { return nil }
115+
guard let value = value as? T else {
116+
wpAssertionFailure("unexpected value", userInfo: [
117+
"key": key.rawValue,
118+
"actual_type": String(describing: expectedType),
119+
"expected_type": String(describing: type(of: value))
120+
])
121+
return nil
122+
}
123+
return value
124+
}
125+
126+
/// Retrieves a metadata value by key as String (convenience method)
127+
/// - Parameter key: The metadata key to search for
128+
/// - Returns: The value as String if found and convertible, nil otherwise
129+
public func getString(for key: Key) -> String? {
130+
getValue(String.self, forKey: key)
131+
}
132+
133+
/// Sets or updates a metadata item with any JSON-compatible value
134+
/// - Parameters:
135+
/// - value: The metadata value (must be JSON-compatible)
136+
/// - key: The metadata key
137+
/// - id: Optional metadata ID
138+
public mutating func setValue(_ value: Any, for key: Key, id: String? = nil) {
139+
var dict: [String: Any] = [
140+
"key": key.rawValue,
141+
"value": value
142+
]
143+
// Preserve existing ID if not provided
144+
if let id {
145+
dict["id"] = id
146+
} else if let existingDict = items[key], let existingID = existingDict["id"] {
147+
dict["id"] = existingID
148+
}
149+
guard JSONSerialization.isValidJSONObject(dict) else {
150+
return wpAssertionFailure("invalid value", userInfo: ["type": String(describing: type(of: value))])
151+
}
152+
items[key] = dict
153+
}
154+
155+
/// Removes a metadata item by key
156+
/// - Parameter key: The metadata key to remove
157+
/// - Returns: True if the item was found and removed, false otherwise
158+
@discardableResult
159+
public mutating func removeValue(for key: Key) -> Bool {
160+
items.removeValue(forKey: key) != nil
161+
}
162+
163+
/// Clears all metadata
164+
public mutating func clear() {
165+
items.removeAll()
166+
}
167+
168+
/// Returns the complete dictionary entry for the given key.
169+
///
170+
/// - Parameter key: The metadata key to retrieve
171+
/// - Returns: The complete metadata dictionary containing "key", "value", and optional "id", or nil if not found
172+
public func entry(forKey key: Key) -> [String: Any]? {
173+
return items[key]
174+
}
175+
}
176+
177+
// MARK: - PostMetadata (Jetpack)
178+
179+
extension PostMetadata.Key {
180+
/// Jetpack Newsletter access level metadata key
181+
public static let jetpackNewsletterAccess: PostMetadata.Key = "_jetpack_newsletter_access"
182+
}
183+
184+
extension PostMetadata {
185+
/// Gets or sets the Jetpack Newsletter access level as a PostAccessLevel enum
186+
public var accessLevel: JetpackPostAccessLevel? {
187+
get {
188+
guard let value = getString(for: .jetpackNewsletterAccess) else { return nil }
189+
return JetpackPostAccessLevel(rawValue: value)
190+
}
191+
set {
192+
if let newValue {
193+
setValue(newValue.rawValue, for: .jetpackNewsletterAccess)
194+
} else {
195+
removeValue(for: .jetpackNewsletterAccess)
196+
}
197+
}
198+
}
199+
}
200+
201+
/// Valid access levels for Jetpack Newsletter
202+
public enum JetpackPostAccessLevel: String, CaseIterable, Hashable, Codable {
203+
case everybody = "everybody"
204+
case subscribers = "subscribers"
205+
case paidSubscribers = "paid_subscribers"
206+
}

Sources/WordPressData/Swift/RemotePostCreateParameters+Helpers.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,29 @@ extension RemotePostCreateParameters {
3030
categoryIDs = (post.categories ?? []).compactMap {
3131
$0.categoryID?.intValue
3232
}
33-
metadata = Set(PostHelper.remoteMetadata(for: post).compactMap { value -> RemotePostMetadataItem? in
34-
guard let dictionary = value as? [String: Any] else {
35-
wpAssertionFailure("Unexpected value", userInfo: [
36-
"value": value
37-
])
38-
return nil
39-
}
33+
metadata = Set(Self.generateRemoteMetadata(for: post).compactMap { dictionary -> RemotePostMetadataItem? in
4034
return PostHelper.mapDictionaryToMetadataItems(dictionary)
4135
})
4236
default:
4337
break
4438
}
4539
}
4640
}
41+
42+
private extension RemotePostCreateParameters {
43+
/// Generates remote metadata for the given post.
44+
///
45+
/// - note: It includes _only_ the keys known to the app and that you as a
46+
/// user can change from the app.
47+
static func generateRemoteMetadata(for post: Post) -> [[String: Any]] {
48+
// Start with existing metadata from PostHelper
49+
var output = PostHelper.remoteMetadata(for: post) as? [[String: Any]] ?? []
50+
51+
// Add Jetpack Newsletter access level metadata
52+
let metadata = PostMetadata(post)
53+
if let entry = metadata.entry(forKey: .jetpackNewsletterAccess) {
54+
output.append(entry)
55+
}
56+
return output
57+
}
58+
}

Sources/WordPressData/Swift/RemotePostUpdateParameters+Helpers.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import WordPressKit
22

3-
public extension RemotePostUpdateParameters {
3+
extension RemotePostUpdateParameters {
44

5-
var isEmpty: Bool {
5+
public var isEmpty: Bool {
66
self == RemotePostUpdateParameters()
77
}
88

99
/// Returns a diff between the original and the latest revision with the
1010
/// changes applied on top.
11-
static func changes(from original: AbstractPost, to latest: AbstractPost, with changes: RemotePostUpdateParameters? = nil) -> RemotePostUpdateParameters {
11+
public static func changes(
12+
from original: AbstractPost,
13+
to latest: AbstractPost,
14+
with changes: RemotePostUpdateParameters? = nil
15+
) -> RemotePostUpdateParameters {
1216
guard original !== latest else {
1317
return changes ?? RemotePostUpdateParameters()
1418
}

0 commit comments

Comments
 (0)