'
+ );
+ expect( result.changed ).toBe( true );
+
+ // Second call should report no further changes.
+ const second = await editor.getTitleAndContent();
+ expect( second.changed ).toBe( false );
+ } );
+} );
diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
index b2aab0be4..68d6e0b8a 100644
--- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
+++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
@@ -425,7 +425,7 @@ class SitePreparationViewModel {
return EditorConfigurationBuilder(
postType: selectedPostTypeDetails,
- siteURL: URL(string: apiRoot.siteUrlString())!,
+ siteURL: URL(string: apiRoot.homeUrlString())!,
siteApiRoot: siteApiRoot
)
.setShouldUseThemeStyles(canUseEditorStyles)
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index 1993d818d..dbe9fe522 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -374,19 +374,25 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
evaluate("editor.setContent('\(escapedString)');", isCritical: true)
}
- /// Returns the current editor content.
- public func getContent() async throws -> String {
- try await webView.evaluateJavaScript("editor.getContent();") as! String
- }
-
public struct EditorTitleAndContent: Decodable {
public let title: String
public let content: String
public let changed: Bool
}
+ /// Returns just the current editor content, without the title.
+ ///
+ /// Use this when the editor is used without a title field (e.g. as a
+ /// comment editor). Delegates to `getTitleAndContent()` internally so
+ /// the same normalization is applied.
+ public func getContent() async throws -> String {
+ let result = try await getTitleAndContent()
+ return result.content
+ }
+
/// Returns the current editor title and content.
public func getTitleAndContent() async throws -> EditorTitleAndContent {
+ guard isReady else { throw EditorNotReadyError() }
let result = try await webView.evaluateJavaScript("editor.getTitleAndContent();")
guard let dictionary = result as? [String: Any],
let title = dictionary["title"] as? String,
@@ -399,23 +405,26 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// Steps backwards in the editor history state
public func undo() {
+ guard isReady else { return }
evaluate("editor.undo();")
}
/// Steps forwards in the editor history state
public func redo() {
+ guard isReady else { return }
evaluate("editor.redo();")
}
/// Dismisses the topmost modal dialog or menu in the editor
public func dismissTopModal() {
+ guard isReady else { return }
evaluate("editor.dismissTopModal();")
}
/// Enables code editor.
public var isCodeEditorEnabled: Bool = false {
didSet {
- guard isCodeEditorEnabled != oldValue else { return }
+ guard isCodeEditorEnabled != oldValue, isReady else { return }
evaluate("editor.switchEditorMode('\(isCodeEditorEnabled ? "text" : "visual")');")
}
}
@@ -573,6 +582,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
///
/// - parameter text: The text to append at the cursor position.
public func appendTextAtCursor(_ text: String) {
+ guard isReady else { return }
let escapedText = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text
evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));")
}
@@ -679,6 +689,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) {
+ // Reset readiness so JS bridge calls are blocked until the editor
+ // re-emits onEditorLoaded after the reload completes.
+ self.isReady = false
webView.reload()
}
@@ -734,6 +747,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
}
+/// Error thrown when a JS bridge method is called before the editor is ready.
+public struct EditorNotReadyError: LocalizedError {
+ public var errorDescription: String? {
+ "The editor is not ready. Wait for editorDidLoad before calling JS bridge methods."
+ }
+}
+
@MainActor
private protocol GutenbergEditorControllerDelegate: AnyObject {
func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage)
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift
index 979acd61b..e614f7259 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift
@@ -18,7 +18,7 @@ public protocol EditorViewControllerDelegate: AnyObject {
/// Notifies the client about the new edits.
///
- /// - note: To get the latest content, call ``EditorViewController/getContent()``.
+ /// - note: To get the latest content, call ``EditorViewController/getTitleAndContent()``.
/// Retrieving the content is a relatively expensive operation and should not
/// be performed too frequently during editing.
///
diff --git a/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift
index 03b83c560..943a82903 100644
--- a/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift
+++ b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift
@@ -4,5 +4,5 @@
/// GutenbergKit version information.
public enum GutenbergKitVersion {
/// The current version of GutenbergKit.
- public static let version = "0.14.0"
+ public static let version = "0.15.0"
}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
new file mode 100644
index 000000000..c06a0fa79
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
@@ -0,0 +1,536 @@
+import Foundation
+import GutenbergKitHTTP
+import OSLog
+
+/// A local HTTP server that receives file uploads from the WebView and routes
+/// them through the native media processing pipeline.
+///
+/// Built on ``HTTPServer`` from `GutenbergKitHTTP`, which handles TCP binding,
+/// HTTP parsing, bearer token authentication, and multipart form-data parsing.
+/// This class provides the upload-specific handler: receiving a file, delegating
+/// to the host app for processing/upload, and returning the result as JSON.
+///
+/// Lifecycle is tied to `EditorViewController` — start when the editor loads,
+/// stop on deinit.
+final class MediaUploadServer: Sendable {
+
+ /// The port the server is listening on.
+ let port: UInt16
+
+ /// Per-session auth token for validating incoming requests.
+ let token: String
+
+ private let server: HTTPServer
+
+ /// Creates and starts a new upload server.
+ ///
+ /// - Parameters:
+ /// - uploadDelegate: Optional delegate for customizing file processing and upload.
+ /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`.
+ /// - maxRequestBodySize: The maximum allowed request body size in bytes.
+ /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB.
+ static func start(
+ uploadDelegate: (any MediaUploadDelegate)? = nil,
+ defaultUploader: DefaultMediaUploader? = nil,
+ maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize
+ ) async throws -> MediaUploadServer {
+ let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader)
+
+ let server = try await HTTPServer.start(
+ name: "media-upload",
+ requiresAuthentication: true,
+ maxRequestBodySize: maxRequestBodySize,
+ handler: { request in
+ await Self.handleRequest(request, context: context)
+ }
+ )
+
+ return MediaUploadServer(server: server)
+ }
+
+ private init(server: HTTPServer) {
+ self.server = server
+ self.port = server.port
+ self.token = server.token
+ }
+
+ /// Stops the server and releases resources.
+ func stop() {
+ server.stop()
+ }
+
+ // MARK: - Request Handling
+
+ private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse {
+ let parsed = request.parsed
+
+ // Server-detected error (e.g., payload too large) — build the
+ // error response here so it includes CORS headers.
+ if let serverError = request.serverError {
+ let message: String = switch serverError {
+ case .payloadTooLarge: "The file is too large to upload in the editor."
+ default: "\(serverError.httpStatusText)"
+ }
+ return errorResponse(status: serverError.httpStatus, body: message)
+ }
+
+ // CORS preflight — the library exempts OPTIONS from auth, so this is
+ // reached without a token.
+ if parsed.method.uppercased() == "OPTIONS" {
+ return corsPreflightResponse()
+ }
+
+ // Route: only POST /upload is handled.
+ guard parsed.method.uppercased() == "POST", parsed.target == "/upload" else {
+ return errorResponse(status: 404, body: "Not found")
+ }
+
+ return await handleUpload(request, context: context)
+ }
+
+ private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse {
+ let parts: [MultipartPart]
+ do {
+ parts = try request.parsed.multipartParts()
+ } catch {
+ Logger.uploadServer.error("Multipart parse failed: \(error)")
+ return errorResponse(status: 400, body: "Expected multipart/form-data")
+ }
+
+ // Find the file part (the first part with a filename).
+ guard let filePart = parts.first(where: { $0.filename != nil }) else {
+ return errorResponse(status: 400, body: "No file found in request")
+ }
+
+ // Write part body to a dedicated temp file for the delegate.
+ //
+ // The library's RequestBody may be a byte-range slice of a larger temp
+ // file whose lifecycle is tied to ARC. The delegate needs a standalone
+ // file that outlives the handler return, so we stream to our own file.
+ let filename = sanitizeFilename(filePart.filename ?? "upload")
+ let mimeType = filePart.contentType
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
+ try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+
+ let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(filename)")
+ do {
+ let inputStream = try filePart.body.makeInputStream()
+ try writeStream(inputStream, to: fileURL)
+ } catch {
+ Logger.uploadServer.error("Failed to write upload to disk: \(error)")
+ return errorResponse(status: 500, body: "Failed to save file")
+ }
+
+ // Process and upload through the delegate pipeline.
+ let result: Result
+ var processedURL: URL?
+ do {
+ let uploadResult = try await processAndUpload(
+ fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context
+ )
+ switch uploadResult {
+ case .uploaded(let media, let processed):
+ processedURL = processed
+ Logger.uploadServer.debug("Uploading processed file to WordPress")
+ result = .success(media)
+ case .passthrough:
+ // Delegate didn't modify the file — forward the original
+ // request body to WordPress without re-encoding.
+ Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress")
+ guard let body = request.parsed.body,
+ let contentType = request.parsed.header("Content-Type"),
+ let defaultUploader = context.defaultUploader else {
+ result = .failure(UploadError.noUploader)
+ break
+ }
+ let media = try await defaultUploader.passthroughUpload(body: body, contentType: contentType)
+ result = .success(media)
+ }
+ } catch {
+ result = .failure(error)
+ }
+
+ // Clean up temp files (success or failure).
+ try? FileManager.default.removeItem(at: fileURL)
+ if let processedURL, processedURL != fileURL {
+ try? FileManager.default.removeItem(at: processedURL)
+ }
+
+ switch result {
+ case .success(let media):
+ do {
+ let json = try JSONEncoder().encode(media)
+ return HTTPResponse(
+ status: 200,
+ headers: corsHeaders + [("Content-Type", "application/json")],
+ body: json
+ )
+ } catch {
+ return errorResponse(status: 500, body: "Failed to encode response")
+ }
+ case .failure(let error):
+ Logger.uploadServer.error("Upload processing failed: \(error)")
+ return errorResponse(status: 500, body: error.localizedDescription)
+ }
+ }
+
+ // MARK: - Delegate Pipeline
+
+ /// Result of the delegate processing + upload pipeline.
+ private enum UploadResult {
+ /// The delegate (or default uploader) completed the upload.
+ case uploaded(MediaUploadResult, processedURL: URL)
+ /// The delegate didn't modify the file and `uploadFile` returned nil.
+ /// The caller should forward the original request body to WordPress.
+ case passthrough
+ }
+
+ private static func processAndUpload(
+ fileURL: URL, mimeType: String, filename: String, context: UploadContext
+ ) async throws -> UploadResult {
+ // Step 1: Process (resize, transcode, etc.)
+ let processedURL: URL
+ if let delegate = context.uploadDelegate {
+ processedURL = try await delegate.processFile(at: fileURL, mimeType: mimeType)
+ } else {
+ processedURL = fileURL
+ }
+
+ // Step 2: Upload to remote WordPress
+ if let delegate = context.uploadDelegate,
+ let result = try await delegate.uploadFile(at: processedURL, mimeType: mimeType, filename: filename) {
+ return .uploaded(result, processedURL: processedURL)
+ } else if let defaultUploader = context.defaultUploader {
+ // If the delegate didn't modify the file, the original request
+ // body can be forwarded directly — skip multipart re-encoding.
+ if processedURL == fileURL {
+ return .passthrough
+ }
+ let result = try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename)
+ return .uploaded(result, processedURL: processedURL)
+ } else {
+ throw UploadError.noUploader
+ }
+ }
+
+ // MARK: - CORS
+
+ private static let corsHeaders: [(String, String)] = [
+ ("Access-Control-Allow-Origin", "*"),
+ ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"),
+ ]
+
+ private static func corsPreflightResponse() -> HTTPResponse {
+ HTTPResponse(
+ status: 204,
+ headers: corsHeaders + [
+ ("Access-Control-Allow-Methods", "POST, OPTIONS"),
+ ("Access-Control-Max-Age", "86400"),
+ ],
+ body: Data()
+ )
+ }
+
+ private static func errorResponse(status: Int, body: String) -> HTTPResponse {
+ HTTPResponse(
+ status: status,
+ headers: corsHeaders + [("Content-Type", "text/plain")],
+ body: Data(body.utf8)
+ )
+ }
+
+ // MARK: - Helpers
+
+ /// Sanitizes a filename to prevent path traversal.
+ private static func sanitizeFilename(_ name: String) -> String {
+ let safe = (name as NSString).lastPathComponent
+ .replacingOccurrences(of: "/", with: "")
+ .replacingOccurrences(of: "\\", with: "")
+ return safe.isEmpty ? "upload" : safe
+ }
+
+ /// Streams an InputStream to a file URL.
+ private static func writeStream(_ inputStream: InputStream, to url: URL) throws {
+ inputStream.open()
+ defer { inputStream.close() }
+
+ let outputStream = OutputStream(url: url, append: false)!
+ outputStream.open()
+ defer { outputStream.close() }
+
+ let bufferSize = 65_536
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+
+ // Use read() return value as the sole termination signal. Do NOT check
+ // hasBytesAvailable — for piped streams (used by file-slice RequestBody),
+ // it can return false before the writer thread has pumped the next chunk,
+ // causing an early exit and a truncated file.
+ while true {
+ let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
+ if bytesRead < 0 {
+ throw inputStream.streamError ?? UploadError.streamReadFailed
+ }
+ if bytesRead == 0 { break }
+
+ var totalWritten = 0
+ while totalWritten < bytesRead {
+ let written = outputStream.write(buffer.advanced(by: totalWritten), maxLength: bytesRead - totalWritten)
+ if written < 0 {
+ throw outputStream.streamError ?? UploadError.streamWriteFailed
+ }
+ totalWritten += written
+ }
+ }
+ }
+
+ // MARK: - Errors
+
+ enum UploadError: Error, LocalizedError {
+ case noUploader
+ case streamReadFailed
+ case streamWriteFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .noUploader: "No upload delegate or default uploader configured"
+ case .streamReadFailed: "Failed to read upload stream"
+ case .streamWriteFailed: "Failed to write upload to disk"
+ }
+ }
+ }
+}
+
+// MARK: - Upload Context
+
+/// Thread-safe container for the upload delegate and default uploader,
+/// captured by the HTTPServer handler closure.
+private struct UploadContext: Sendable {
+ let uploadDelegate: (any MediaUploadDelegate)?
+ let defaultUploader: DefaultMediaUploader?
+}
+
+// MARK: - Default Media Uploader
+
+/// Uploads files to the WordPress REST API using site credentials from EditorConfiguration.
+class DefaultMediaUploader: @unchecked Sendable {
+ private let httpClient: EditorHTTPClientProtocol
+ private let siteApiRoot: URL
+ private let siteApiNamespace: String?
+
+ init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) {
+ self.httpClient = httpClient
+ self.siteApiRoot = siteApiRoot
+ self.siteApiNamespace = siteApiNamespace.first
+ }
+
+ /// The WordPress media endpoint URL, accounting for site API namespaces.
+ private var mediaEndpointURL: URL {
+ let mediaPath = if let siteApiNamespace {
+ "wp/v2/\(siteApiNamespace)media"
+ } else {
+ "wp/v2/media"
+ }
+ return siteApiRoot.appending(path: mediaPath)
+ }
+
+ func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult {
+ let boundary = UUID().uuidString
+
+ let (bodyStream, contentLength) = try Self.multipartBodyStream(
+ fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType
+ )
+
+ var request = URLRequest(url: mediaEndpointURL)
+ request.httpMethod = "POST"
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length")
+ request.httpBodyStream = bodyStream
+
+ return try await performUpload(request)
+ }
+
+ /// Forwards the original request body to WordPress without re-encoding.
+ ///
+ /// Used when the delegate's `processFile` returned the file unchanged —
+ /// the incoming multipart body is already valid for WordPress.
+ func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult {
+ var request = URLRequest(url: mediaEndpointURL)
+ request.httpMethod = "POST"
+ request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
+ request.httpBodyStream = try body.makeInputStream()
+
+ return try await performUpload(request)
+ }
+
+ private func performUpload(_ request: URLRequest) async throws -> MediaUploadResult {
+ let (data, response) = try await httpClient.perform(request)
+
+ guard (200...299).contains(response.statusCode) else {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ throw MediaUploadError.uploadFailed(statusCode: response.statusCode, preview: preview)
+ }
+
+ // Parse the WordPress media response into our result type
+ let wpMedia: WPMediaResponse
+ do {
+ wpMedia = try JSONDecoder().decode(WPMediaResponse.self, from: data)
+ } catch {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ throw MediaUploadError.unexpectedResponse(preview: preview, underlyingError: error)
+ }
+
+ return MediaUploadResult(
+ id: wpMedia.id,
+ url: wpMedia.source_url,
+ alt: wpMedia.alt_text ?? "",
+ caption: wpMedia.caption?.rendered ?? "",
+ title: wpMedia.title.rendered,
+ mime: wpMedia.mime_type,
+ type: wpMedia.media_type,
+ width: wpMedia.media_details?.width,
+ height: wpMedia.media_details?.height
+ )
+ }
+
+ // MARK: - Streaming Multipart Body
+
+ /// Builds a multipart/form-data body as an `InputStream` that streams the
+ /// file from disk without loading it into memory.
+ ///
+ /// Uses a bound stream pair with a background writer thread — the same
+ /// pattern as `RequestBody.makePipedFileSliceStream`.
+ ///
+ /// - Returns: A tuple of the input stream and the total content length.
+ static func multipartBodyStream(
+ fileURL: URL,
+ boundary: String,
+ filename: String,
+ mimeType: String
+ ) throws -> (InputStream, Int) {
+ let preamble = Data(
+ ("--\(boundary)\r\n"
+ + "Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n"
+ + "Content-Type: \(mimeType)\r\n\r\n").utf8
+ )
+ let epilogue = Data("\r\n--\(boundary)--\r\n".utf8)
+
+ guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else {
+ throw MediaUploadError.streamReadFailed
+ }
+ let contentLength = preamble.count + fileSize + epilogue.count
+
+ let fileHandle = try FileHandle(forReadingFrom: fileURL)
+
+ var readStream: InputStream?
+ var writeStream: OutputStream?
+ Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream)
+
+ guard let inputStream = readStream, let outputStream = writeStream else {
+ try? fileHandle.close()
+ throw MediaUploadError.streamReadFailed
+ }
+
+ outputStream.open()
+
+ // OutputStream is not Sendable but is safely transferred to the
+ // writer thread — only the thread accesses it after this point.
+ nonisolated(unsafe) let output = outputStream
+
+ Thread.detachNewThread {
+ defer {
+ output.close()
+ try? fileHandle.close()
+ }
+
+ // Write preamble (multipart headers).
+ guard Self.writeAll(preamble, to: output) else { return }
+
+ // Stream file content in chunks.
+ var remaining = fileSize
+ while remaining > 0 {
+ let chunkSize = min(65_536, remaining)
+ guard let chunk = try? fileHandle.read(upToCount: chunkSize),
+ !chunk.isEmpty else {
+ break
+ }
+ guard Self.writeAll(chunk, to: output) else { return }
+ remaining -= chunk.count
+ }
+
+ // Write epilogue (closing boundary).
+ _ = Self.writeAll(epilogue, to: output)
+ }
+
+ return (inputStream, contentLength)
+ }
+
+ /// Writes all bytes of `data` to the output stream, handling partial writes.
+ private static func writeAll(_ data: Data, to output: OutputStream) -> Bool {
+ data.withUnsafeBytes { buffer in
+ guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false }
+ var written = 0
+ while written < data.count {
+ let result = output.write(base.advanced(by: written), maxLength: data.count - written)
+ if result <= 0 { return false }
+ written += result
+ }
+ return true
+ }
+ }
+}
+
+/// WordPress REST API media response (subset of fields).
+private struct WPMediaResponse: Decodable {
+ let id: Int
+ let source_url: String
+ let alt_text: String?
+ let caption: RenderedField?
+ let title: RenderedField
+ let mime_type: String
+ let media_type: String
+ let media_details: MediaDetails?
+
+ struct RenderedField: Decodable {
+ let rendered: String
+ }
+
+ struct MediaDetails: Decodable {
+ let width: Int?
+ let height: Int?
+ }
+}
+
+/// Errors specific to the native media upload pipeline.
+enum MediaUploadError: Error, LocalizedError {
+ /// The WordPress REST API returned a non-success HTTP status code.
+ case uploadFailed(statusCode: Int, preview: String)
+
+ /// The WordPress REST API returned a non-JSON response (e.g. HTML error page).
+ case unexpectedResponse(preview: String, underlyingError: Error)
+
+ /// Failed to read the file for streaming upload.
+ case streamReadFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .uploadFailed(let statusCode, let preview):
+ return "Upload failed (\(statusCode)): \(preview)"
+ case .unexpectedResponse(let preview, _):
+ return "WordPress returned an unexpected response: \(preview)"
+ case .streamReadFailed:
+ return "Failed to read file for upload"
+ }
+ }
+}
+
+// MARK: - Helpers
+
+private extension Data {
+ mutating func append(_ string: String) {
+ append(Data(string.utf8))
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
index 92d8bfa37..69661facd 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
@@ -93,7 +93,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable {
) {
self.title = title
self.content = content
- self.postID = postID
+ self.postID = postID == 0 ? nil : postID
self.postType = postType
self.postStatus = postStatus
self.shouldUseThemeStyles = shouldUseThemeStyles
diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
index efe3d9ad8..91b53fd56 100644
--- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
+++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
@@ -61,10 +61,12 @@ public struct RESTAPIRepository: Sendable {
/// Builds a URL by inserting the namespace after the version segment of the path.
/// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts`
private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL {
- guard let namespace = namespace else {
+ guard let rawNamespace = namespace else {
return apiRoot.appending(rawPath: path)
}
+ let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/"
+
// Parse the path to find where to insert the namespace
// Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings)
let components = path.split(separator: "/", omittingEmptySubsequences: true)
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
index 293c92773..186946493 100644
--- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
+++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
@@ -27,6 +27,10 @@ public final class HTTPRequestParser: @unchecked Sendable {
case needsMoreData
/// Headers have been fully received but the body is still incomplete.
case headersComplete
+ /// The request body exceeds the maximum allowed size and is being
+ /// drained (read and discarded) so the server can send a clean 413
+ /// response. No body bytes are buffered in this state.
+ case draining
/// All data has been received (headers and body).
case complete
}
@@ -46,7 +50,7 @@ public final class HTTPRequestParser: @unchecked Sendable {
private var buffer: Buffer
private let maxBodySize: Int64
private let inMemoryBodyThreshold: Int
- private var bytesWritten: Int = 0
+ private var bytesWritten: Int64 = 0
private var _state: State = .needsMoreData
// Lightweight scan results (populated by append)
@@ -103,6 +107,15 @@ public final class HTTPRequestParser: @unchecked Sendable {
lock.withLock { _state }
}
+ /// The parse error detected during buffering, if any.
+ ///
+ /// Non-fatal errors like ``HTTPRequestParseError/payloadTooLarge`` are
+ /// exposed here instead of being thrown by ``parseRequest()``, allowing
+ /// the caller to still access the parsed headers.
+ public var parseError: HTTPRequestParseError? {
+ lock.withLock { _parseError }
+ }
+
/// The expected body length from `Content-Length`, available once headers have been received.
public var expectedBodyLength: Int64? {
lock.withLock {
@@ -124,12 +137,16 @@ public final class HTTPRequestParser: @unchecked Sendable {
try lock.withLock {
guard _state.hasHeaders else { return nil }
- if let error = _parseError {
+ // Payload-too-large means "valid headers, rejected body" — let
+ // the caller access the parsed headers so the handler can build
+ // a response (e.g., with CORS headers). Other parse errors
+ // indicate genuinely malformed requests and are still thrown.
+ if let error = _parseError, error != .payloadTooLarge {
throw error
}
if _parsedHeaders == nil {
- let headerData = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize))
+ let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
switch HTTPRequestSerializer.parseHeaders(from: headerData) {
case .parsed(let headers):
_parsedHeaders = headers
@@ -143,7 +160,11 @@ public final class HTTPRequestParser: @unchecked Sendable {
guard let headers = _parsedHeaders else { return nil }
- guard _state.isComplete else {
+ // Return partial (headers only) when the body was rejected or
+ // hasn't fully arrived yet. The payloadTooLarge case goes through
+ // drain mode which discards body bytes without buffering them, so
+ // there is no body to extract even though the state is .complete.
+ guard _state.isComplete, _parseError == nil else {
return .partial(
method: headers.method,
target: headers.target,
@@ -179,6 +200,17 @@ public final class HTTPRequestParser: @unchecked Sendable {
lock.withLock {
guard !_state.isComplete else { return }
+ // In drain mode, discard bytes without buffering and check
+ // whether the full Content-Length has been consumed.
+ if case .draining = _state {
+ bytesWritten += Int64(data.count)
+ if let offset = headerEndOffset,
+ bytesWritten - Int64(offset) >= expectedContentLength {
+ _state = .complete
+ }
+ return
+ }
+
let accepted: Bool
do {
accepted = try buffer.append(data)
@@ -192,12 +224,12 @@ public final class HTTPRequestParser: @unchecked Sendable {
_state = .complete
return
}
- bytesWritten += data.count
+ bytesWritten += Int64(data.count)
if headerEndOffset == nil {
let buffered: Data
do {
- buffered = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize))
+ buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
} catch {
_parseError = .bufferIOError
_state = .complete
@@ -215,7 +247,7 @@ public final class HTTPRequestParser: @unchecked Sendable {
let effectiveData = buffered[scanStart...]
guard let separatorRange = effectiveData.range(of: separator) else {
- if bytesWritten > Self.maxHeaderSize {
+ if bytesWritten > Int64(Self.maxHeaderSize) {
_parseError = .headersTooLarge
_state = .complete
} else {
@@ -236,15 +268,15 @@ public final class HTTPRequestParser: @unchecked Sendable {
if expectedContentLength > maxBodySize {
_parseError = .payloadTooLarge
- _state = .complete
+ _state = .draining
return
}
}
guard let offset = headerEndOffset else { return }
- let bodyBytesAvailable = bytesWritten - offset
+ let bodyBytesAvailable = bytesWritten - Int64(offset)
- if Int64(bodyBytesAvailable) >= expectedContentLength {
+ if bodyBytesAvailable >= expectedContentLength {
_state = .complete
} else {
_state = .headersComplete
@@ -403,14 +435,16 @@ extension HTTPRequestParser.State {
/// Whether all data has been received (headers and body).
public var isComplete: Bool {
- if case .complete = self { return true }
- return false
+ switch self {
+ case .complete: return true
+ case .needsMoreData, .headersComplete, .draining: return false
+ }
}
- /// Whether headers have been fully received (true for both `.headersComplete` and `.complete`).
+ /// Whether headers have been fully received (true for `.headersComplete`, `.draining`, and `.complete`).
public var hasHeaders: Bool {
switch self {
- case .headersComplete, .complete: return true
+ case .headersComplete, .draining, .complete: return true
case .needsMoreData: return false
}
}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
index 17cfeb1c7..485cc9b79 100644
--- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
+++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
@@ -626,4 +626,4 @@ extension Logger {
static let httpServer = Logger(subsystem: "com.gutenbergkit.http", category: "server")
}
-#endif // canImport(Network)
+#endif // canImport(Network)
\ No newline at end of file
diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js
new file mode 100644
index 000000000..060118bcd
--- /dev/null
+++ b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js
@@ -0,0 +1 @@
+import{r as e}from"./index-DiCwSwMw.js";var t=window.wp.apiFetch,{getQueryArg:n}=window.wp.url;function r(){let{siteApiRoot:n=``,preloadData:r=null}=e();t.use(t.createRootURLMiddleware(n)),t.use(i),t.use(a),t.use(o),t.use(s),t.use(c),t.use(l),t.use(t.createPreloadingMiddleware(r??u))}function i(e,t){return e.mode=`cors`,e.headers&&delete e.headers[`x-wp-api-fetch-from-editor`],t(e)}function a(t,n){let{siteApiNamespace:r,namespaceExcludedPaths:i}=e(),a=RegExp(`(${r.join(`|`)})`),o=t.path&&!i.some(e=>t.path.startsWith(e)),s=a.test(t.path)||/\/sites\/[^/]+\//.test(t.path);return o&&!s&&(t.path=t.path.replace(/^(?\/?(?:[\w.-]+\/){2})/,`$${r[0]}`)),n(t)}function o(t,n){let{authHeader:r}=e();return t.headers=t.headers||{},r&&(t.headers.Authorization=r,t.credentials=`omit`),n(t)}function s(t,n){let{post:r}=e(),{id:i,restNamespace:a,restBase:o}=r??{};if(i===void 0||!a||!o)return n(t);let s=`/${a}/${o}/${i}`;return t.path===s||t.path?.startsWith(`${s}?`)?Promise.resolve([]):n(t)}function c(e,t){return e.path&&e.path.startsWith(`/wp/v2/media`)&&e.method===`POST`&&e.body instanceof FormData&&e.body.get(`post`)===`-1`&&e.body.delete(`post`),t(e)}function l(e,t){if(e.path&&e.path.indexOf(`oembed`)!==-1){let r=n(e.path,`url`),i=t(e,t);function a(){let e=document.createElement(`a`);return e.href=r,e.innerText=r,{html:e.outerHTML,type:`rich`,provider_name:`Embed`}}return new Promise(e=>{i.then(t=>{if(t.html){let e=document.implementation.createHTMLDocument(``);e.body.innerHTML=t.html;let n=[`[class="embed-youtube"]`,`[class="embed-vimeo"]`,`[class="embed-dailymotion"]`,`[class="embed-ted"]`].join(`,`),r=e.querySelector(n);t.html=r?r.innerHTML:t.html}e(t)}).catch(()=>{e(a())})})}return t(e,t)}var u={"/wp/v2/types?context=view":{body:{post:{description:``,hierarchical:!1,has_archive:!1,name:`Posts`,slug:`post`,taxonomies:[`category`,`post_tag`],rest_base:`posts`,rest_namespace:`wp/v2`,template:[],template_lock:!1,_links:{}},page:{description:``,hierarchical:!0,has_archive:!1,name:`Pages`,slug:`page`,taxonomies:[],rest_base:`pages`,rest_namespace:`wp/v2`,template:[],template_lock:!1,_links:{}}}},"/wp/v2/types/post?context=edit":{body:{name:`Posts`,slug:`post`,supports:{title:!0,editor:!0,author:!0,thumbnail:!0,excerpt:!0,trackbacks:!0,"custom-fields":!0,comments:!0,revisions:!0,"post-formats":!0,autosave:!0},taxonomies:[`category`,`post_tag`],rest_base:`posts`,rest_namespace:`wp/v2`,template:[],template_lock:!1}}};export{r as configureApiFetch};
\ No newline at end of file
diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js
deleted file mode 100644
index 52bb4a4fb..000000000
--- a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js
+++ /dev/null
@@ -1 +0,0 @@
-import{m as o}from"./index-DIUPWyT0.js";const c=window.wp.apiFetch,{getQueryArg:m}=window.wp.url;function P(){const{siteApiRoot:e="",preloadData:t=null}=o();c.use(c.createRootURLMiddleware(e)),c.use(p),c.use(h),c.use(f),c.use(w),c.use(b),c.use(g),c.use(c.createPreloadingMiddleware(t??_))}function p(e,t){return e.mode="cors",delete e.headers["x-wp-api-fetch-from-editor"],t(e)}function h(e,t){const{siteApiNamespace:a,namespaceExcludedPaths:i}=o(),n=new RegExp(`(${a.join("|")})`);return e.path&&!i.some(s=>e.path.startsWith(s))&&!n.test(e.path)&&(e.path=e.path.replace(/^(?\/?(?:[\w.-]+\/){2})/,`$${a[0]}`)),t(e)}function f(e,t){const{authHeader:a}=o();return e.headers=e.headers||{},a&&(e.headers.Authorization=a,e.credentials="omit"),t(e)}function w(e,t){const{post:a}=o(),{id:i,restNamespace:n,restBase:r}=a??{};if(i===void 0||!n||!r)return t(e);const s=`/${n}/${r}/${i}`;return e.path===s||e.path?.startsWith(`${s}?`)?Promise.resolve([]):t(e)}function b(e,t){return e.path&&e.path.startsWith("/wp/v2/media")&&e.method==="POST"&&e.body instanceof FormData&&e.body.get("post")==="-1"&&e.body.delete("post"),t(e)}function g(e,t){if(e.path&&e.path.indexOf("oembed")!==-1){let n=function(){const r=document.createElement("a");return r.href=a,r.innerText=a,{html:r.outerHTML,type:"rich",provider_name:"Embed"}};const a=m(e.path,"url"),i=t(e,t);return new Promise(r=>{i.then(s=>{if(s.html){const l=document.implementation.createHTMLDocument("");l.body.innerHTML=s.html;const d=['[class="embed-youtube"]','[class="embed-vimeo"]','[class="embed-dailymotion"]','[class="embed-ted"]'].join(","),u=l.querySelector(d);s.html=u?u.innerHTML:s.html}r(s)}).catch(()=>{r(n())})})}return t(e,t)}const _={"/wp/v2/types?context=view":{body:{post:{description:"",hierarchical:!1,has_archive:!1,name:"Posts",slug:"post",taxonomies:["category","post_tag"],rest_base:"posts",rest_namespace:"wp/v2",template:[],template_lock:!1,_links:{}},page:{description:"",hierarchical:!0,has_archive:!1,name:"Pages",slug:"page",taxonomies:[],rest_base:"pages",rest_namespace:"wp/v2",template:[],template_lock:!1,_links:{}}}},"/wp/v2/types/post?context=edit":{body:{name:"Posts",slug:"post",supports:{title:!0,editor:!0,author:!0,thumbnail:!0,excerpt:!0,trackbacks:!0,"custom-fields":!0,comments:!0,revisions:!0,"post-formats":!0,autosave:!0},taxonomies:["category","post_tag"],rest_base:"posts",rest_namespace:"wp/v2",template:[],template_lock:!1}}};export{P as configureApiFetch};
diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js
new file mode 100644
index 000000000..ccffade18
--- /dev/null
+++ b/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js
@@ -0,0 +1,12 @@
+var e=[],t=[],n=[],r=[],i=[],a=[],o=[],s=[],c=[],l=[],u=[],d=[],f=[],p=[],m=[],h=[],g=[],_=[],v=[],y=[],b=[],x=[],S=[],C=[],w=[],T=[],E=[],D=[],O=[],k=[],A=[],j=[],M=[],N=[],P=[],F=[],I=[],L=[],R=[],z=[],B=[],V=[],H=[],U=[],W=[],G=[],K=[],q=[],J=[`قطع`],Y=[`تجاهل`],X=[],Z=[],Q=[],$=[],ee=[`تعليقات`],te=[],ne=[`عرض`],re=[],ie=[],ae=[],oe=[`الصفحة الرئيسية`],se=[],ce=[],le=[],ue=[],de=[],fe=[],pe=[],me=[],he=[],ge=[],_e=[],ve=[],ye=[],be=[],xe=[],Se=[`صفوف`],Ce=[],we=[],Te=[],Ee=[],De=[],Oe=[],ke=[],Ae=[],je=[],Me=[],Ne=[],Pe=[],Fe=[],Ie=[],Le=[],Re=[],ze=[],Be=[],Ve=[`البريد الإلكتروني`],He=[`المصدر`],Ue=[`الخطوط`],We=[],Ge=[],Ke=[],qe=[],Je=[],Ye=[`فصل`],Xe=[`كلمة المرور`],Ze=[`هامش`],Qe=[`الأعداد`],$e=[],et=[],tt=[],nt=[],rt=[`بإنتظار المراجعة`],it=[`اقتراحات`],at=[`اللغة`],ot=[],st=[`تفعيل`],ct=[`الدقة`],lt=[`إدراج`],ut=[`Openverse`],dt=[`الظل`],ft=[`وسط`],pt=[`الموضع`],mt=[`مثتبة`],ht=[`التلده`],gt=[`CSS`],_t=[`مقاطع فيديو`],vt=[],yt=[`إنقاص`],bt=[`زيادة`],xt=[`كلمات توضيحية`],St=[`نمط`],Ct=[`مقبض`],wt=[`XXL`],Tt=[`الخط`],Et=[`مقيده`],Dt=[`ع6`],Ot=[`ع5`],kt=[`ع4`],At=[`ع3`],jt=[`ع2`],Mt=[`ع1`],Nt=[`الفئات`],Pt=[`تمرير المؤشر`],Ft=[`ملخص`],It=[`إلغاء تعيين`],Lt=[`الآن`],Rt=[`الآباء`],zt=[`اللاحقة`],Bt=[`البادئة`],Vt=[`يقول`],Ht=[`استجابة`],Ut=[`الردود`],Wt=[`كُدس`],Gt=[`أسبوع`],Kt=[`غير صالح`],qt=[`قفل`],Jt=[`الغاء القفل`],Yt=[`معاينة`],Xt=[`تمّ التنفيذ`],Zt=[`أيقونة`],Qt=[`حذف`],$t=[`إجراءات`],en=[`إعادة تسمية`],tn=[`Aa`],nn=[`الأنماط`],rn=[`قوائم`],an=[`رد`],on=[`العناصر`],sn=[`القوائم الفرعية`],cn=[`دائمًا`],ln=[`العرض`],un=[`إشارة مرجعية`],dn=[`تمييز`],fn=[`طبق الألوان`],pn=[`الألوان`],mn=[`السهم`],hn=[`صف`],gn=[`ضبط`],_n=[`انسياب`],vn=[`الثني`],yn=[`النشر`],bn=[`النمط`],xn=[`نصف القطر`],Sn=[`الهامش`],Cn=[`التراكُب اللوني (Duotone)`],wn=[`الشعار`],Tn=[`التمييز`],En=[`الظِلال`],Dn=[`التخطيط`],On=[`منقط`],kn=[`متقطع`],An=[`تخصيص`],jn=[`إطار`],Mn=[`شبكة`],Nn=[`المنطقة`],Pn=[`إضافة إقتباس/زيادة المسافة البادئة`],Fn=[`إزالة إقتباس/إنقاص المسافة البادئة`],In=[`مرتب`],Ln=[`غير مرتب`],Rn=[`سحب`],zn=[`محاذاة`],Bn=[],Vn=[`الكتابة بأحرف كبيرة`],Hn=[`أحرف صغيرة`],Un=[`أحرف كبيرة`],Wn=[`عمودي`],Gn=[`أفقي`],Kn=[`القوالب`],qn=[`الكلمة المفتاحية`],Jn=[`عوامل التصفية`],Yn=[`زخرفة`],Xn=[`فقط`],Zn=[`استثناء`],Qn=[`تضمين`],$n=[`المظهر`],er=[`التفضيلات`],tr=[`النوع`],nr=[`التسمية`],rr=[`فصول`],ir=[`أوصاف`],ar=[`كلمات توضيحية`],or=[`ترجمات`],sr=[`الوسوم`],cr=[`التفاصيل`],lr=[`شعاعي`],ur=[`خطي`],dr=[`غير معروف`],fr=[`أحرف`],pr=[`الوصف`],mr=[`الأساس`],hr=[`كاتب`],gr=[`الأصل`],_r=[`الاسم`],vr=[`صورة`],yr=[`منظر أفقي`],br=[`مختلط`],xr=[`يمين`],Sr=[`يسار`],Cr=[`أسفل`],wr=[`أعلى`],Tr=[`الحشو`],Er=[`مسافة التباعد`],Dr=[`الإتجاه`],Or=[`قص`],kr=[`تدوير`],Ar=[`تكبير`],jr=[`تصميم`],Mr=[`نصّ`],Nr=[`الإشعارات`],Pr=[`صفحة`,`صفحة واحدة`,`صفحتان`,`صفحات`,`صفحة`,`صفحة`],Fr=[`إزاحة`],Ir=[`مقالات`],Lr=[`صفحات`],Rr=[`غير مصنف`],zr=[`أبيض`],Br=[`أسود`],Vr=[`مُحدَّد`],Hr=[`أحرف علوية`],Ur=[`أحرف سفلية`],Wr=[`الأنماط`],Gr=[`الخطوط`],Kr=[`المحتوى `],qr=[`القائمة`],Jr=[`الاتصال`],Yr=[`حول`],Xr=[`الرئيسية`],Zr=[`المستخدم`],Qr=[`الموقع`],$r=[`إنشاء`],ei=[`سطح المكتب`],ti=[`الجوال`],ni=[`الأجهزة اللوحية`],ri=[`استطلاع رأي`],ii=[`اجتماعي`],ai=[`لون كامل`],oi=[`النوع`],si=[`زاوية`],ci=[`اختيار`],li=[`قالب`],ui=[`فارغ`],di=[`الأزرار`],fi=[`الخلفية`],pi=[`مساعدة`],mi=[`بدون عنوان`],hi=[`التالي`],gi=[`السابق`],_i=[`إنهاء`],vi=[`استبدال`],yi=[`أداة الإدراج`],bi=[`بودكاست`],xi=[`التنقّل`],Si=[`القالب`],Ci=[`التدرّج`],wi=[`أزرق منتصف الليل`],Ti=[`النسخة`],Ei=[`الأبعاد`],Di=[`القوالب`],Oi=[`أضف`],ki=[`اللون`],Ai=[`مُخصص`],ji=[`مسودة`],Mi=[`تخطي`],Ni=[`الروابط`],Pi=[`القائمة`],Fi=[`تذييل`],Ii=[`مجموعة`],Li=[`فئة`],Ri=[`افتراضي`],zi=[`بحث`],Bi=[`التقويم`],Vi=[`رجوع`],Hi=[`كتاب إلكتروني`],Ui=[`تحته خط`],Wi=[`صورة مصغرة`],Gi=[`تعليقات توضيحية`],Ki=[`وسائط`],qi=[`وسائط`],Ji=[`الأنماط`],Yi=[`عام`],Xi=[`الخيارات`],Zi=[`دقائق`],Qi=[`ساعات`],$i=[`الوقت`],ea=[`السنة`],ta=[`اليوم`],na=[`ديسمبر`],ra=[`نوفمبر`],ia=[`أكتوبر`],aa=[`سبتمبر`],oa=[`أغسطس`],sa=[`يوليو`],ca=[`يونيو`],la=[`مايو`],ua=[`أبريل`],da=[`مارس`],fa=[`فبراير`],pa=[`يناير`],ma=[`الشهر`],ha=[`الوقت`],ga=[`غلاف`],_a=[`ضخم`],va=[`متوسط`],ya=[`عادي`],ba=[`العناصر`],xa=[`الصورة الرمزية Avatar`],Sa=[`عرض`],Ca=[`HTML`],wa=[`غِشاء`],Ta=[`فاصلة علوية مائلة Backtick`],Ea=[`فترة`],Da=[`فاصلة`],Oa=[`الحالي`],ka=[`العنوان`],Aa=[`إنشاء`],ja=[`معارض`],Ma=[`XL`],Na=[`L`],Pa=[`M`],Fa=[`S`],Ia=[`صغير`],La=[`تم التجاهل`],Ra=[`تلقائي`],za=[`تحميل مسبق`],Ba=[`الدعم`],Va=[`الأرشيف`],Ha=[`كبير`],Ua=[`ملف`],Wa=[`عمود`],Ga=[`حلقة`],Ka=[`تشغيل تلقائي`],qa=[`حفظ تلقائي`],Ja=[`عنوان فرعي`],Ya=[`موافق`],Xa=[`إزالة الربط`],Za=[`تعدد الصفحات`],Qa=[`الارتفاع`],$a=[`العرض`],eo=[`متقدم`],to=[`مجدول`],no=[`الإضافات`],ro=[`فقرات`],io=[`عناوين`],ao=[`كلمات`],oo=[`عام`],so=[`خاص`],co=[`عنصر`],lo=[`وسم`],uo=[`فوراً`],fo=[`جاري الحفظ`],po=[`منشور`],mo=[`جدولة`],ho=[`تحديث`],go=[`نسخ`],_o=[`محادثة`],vo=[`الحالة`],yo=[`قياسي`],bo=[`الجانب`],xo=[`ترتيب`],So=[`تم الحفظ`],Co=[`التضمينات`],wo=[`مكوّنات`],To=[`تراجع`],Eo=[`إعادة`],Do=[`تكرار`],Oo=[`إزالة`],ko=[`الظهور`],Ao=[`المكوّن`],jo=[`أدوات`],Mo=[`المُحرر`],No=[`الإعدادات`],Po=[`إعادة تعيين`],Fo=[`إيقاف`],Io=[],Lo=[`مساءً`],Ro=[`صباحًا`],zo=[`رابط الـ`],Bo=[`إرسال`],Vo=[`إغلاق`],Ho=[`رابط`],Uo=[`نصّ مشطوب`],Wo=[`مائل`],Go=[`عريض`],Ko=[`تصنيف`],qo=[`تحديد`],Jo=[`فيديو`],Yo=[`جدول`],Xo=[`كود قصير`],Zo=[`فاصل`],Qo=[`اقتباس`],$o=[`فقرة`],es=[`قائمة`],ts=[`صورة`],ns=[`الحجم`],rs=[`صورة`],is=[`معاينة`],as=[`عنوان`],os=[`صور`],ss=[`بدون`],cs=[`معرض`],ls=[`المزيد`],us=[`تقليدي`],ds=[`فيديو`],fs=[`صوتيات`],ps=[`موسيقى`],ms=[`صورة`],hs=[`مدونة`],gs=[`المقالة`],_s=[`أعمدة`],vs=[`التجارب`],ys=[`كود`],bs=[`تصنيفات`],xs=[`زر`],Ss=[`تطبيق`],Cs=[`إلغاء`],ws=[`تحرير`],Ts=[`صوت`],Es=[`رفع`],Ds=[`مسح`],Os=[`ودجات`],ks=[`الكُتّاب`],As=[`الاسم اللطيف`],js=[`التعليق`],Ms=[`مناقشة`],Ns=[`المقتطف`],Ps=[`نشر`],Fs=[`البيانات الوصفية`],Is=[`حفظ`],Ls=[`المراجعات`],Rs=[`وثائق المساعدة`],zs=[`Gutenberg`],Bs=[`عرض توضيحي`],Vs={100:[`100`],"block descriptionDisplay the tab buttons for a tabbed interface.":[],"block titleTabs Menu":[],"block descriptionA single tab button in the tabs menu. Used as a template for styling all tab buttons.":[],"block titleTab Menu Item":[],"block descriptionContainer for tab panel content in a tabbed interface.":[],"block titleTab Panels":[],"Uploading %s file":[],"Uploaded %s file":[],"Open classic revisions screen":[],"Created %s.":[],"Failed to load media file.":[],"No media file available.":[],"View file":[],Exit:e,Revision:t,"Only one revision found.":[],"No revisions found.":[],"Revision restored.":[],"Media updated.":[],"Tab menu item":[],"Hover Text":[],"Hover Background":[],"Active Text":[],"Active Background":[],"Move tab down":[],"Move tab up":[],"Move tab left":[],"Move tab right":[],"Add tabs to display menu":[],"Remove Tab":[],"Remove the current tab":[],"Add a new tab":[],Click:n,"Submenu Visibility":[],"Navigation Overlay template part preview":[],"This overlay is empty.":[],"This overlay template part no longer exists.":[],"%s (missing)":[],"The selected overlay template part is missing or has been deleted. Reset to default overlay or create a new overlay.":[],"Add your own CSS to customize the appearance of the %s block. You do not need to include a CSS selector, just add the property and value, e.g. color: red;.":[],"%d field needs attention":[],"The custom CSS is invalid. Do not use <> markup.":[],"Parent block is hidden on %s":[],"Block is hidden on %s":[],"%1$d of %2$d Item":[],"Enables editing media items (attachments) directly in the block editor with a dedicated media preview and metadata panel.":[],"Media Editor":[],"Block pattern descriptionA navigation overlay with vertically and horizontally centered navigation":[],"Overlay with centered navigation":[],"Get started today!":[],"Find out how we can help your business.":[],"Block pattern descriptionA navigation overlay with vertically and horizontally centered navigation, site info, and a CTA":[],"Overlay with site info and CTA":[],"Block pattern descriptionA navigation overlay with orange background site title and tagline":[],"Overlay with orange background":[],"Block pattern descriptionA navigation overlay with black background and big white text":[],"Overlay with black background":[],'The CSS must not contain "%s".':[],'The CSS must not end in "%s".':[],"block keywordoverlay":[],"block keywordclose":[],"block descriptionA customizable button to close overlays.":[],"block titleNavigation Overlay Close":[],"block descriptionDisplay a breadcrumb trail showing the path to the current page.":[],"Date modified":[],"Date added":[],"Attached to":[],"Search for a post or page to attach this media to.":[],"Search for a post or page to attach this media to or .":[],"(Unattached)":[],"Choose file":[],"Choose files":[],"There is %d event":[],"Exclude: %s":[],Both:r,"Display Mode":[],"Submenu background":[],"Submenu text":[],Deleted:i,"No link selected":[],"External link":[],"Create new overlay template":[],"Select an overlay for navigation.":[],"An error occurred while creating the overlay.":[],'One response to "%s"':[],"Use the classic editor to add content.":[],"Search for and add a link to the navigation item.":[],"Select a link":[],"No items yet.":[],"The text may be too small to read. Consider using a larger container or less text.":[],"Parent block is hidden":[],"Block is hidden":[],Dimension:a,"Set custom value":[],"Use preset":[],Variation:o,"Go to parent block":[],'Go to "%s" block':[],"Block will be hidden according to the selected viewports. It will be included in the published markup on the frontend. You can configure it again by selecting it in the List View (%s).":[],"Block will be hidden in the editor, and omitted from the published markup on the frontend. You can configure it again by selecting it in the List View (%s).":[],"Selected blocks have different visibility settings. The checkboxes show an indeterminate state when settings differ.":[],"Hide on %s":[],"Omit from published content":[],"Select the viewport size for which you want to hide the block.":[],"Select the viewport sizes for which you want to hide the blocks. Changes will apply to all selected blocks.":[],"Hide block":[],"Hide blocks":[],"Block visibility settings updated. You can access them via the List View (%s).":[],"Redirects the default site editor (Appearance > Design) to use the extensible site editor page.":[],"Extensible Site Editor":[],"Enables editable block inspector fields that are generated using a dataform.":[],"Block fields: Show dataform driven inspector fields on blocks that support them":[],"Block pattern descriptionA simple pattern with a navigation block and a navigation overlay close button.":[],"Block pattern categoryDisplay your website navigation.":[],"Block pattern categoryNavigation":[],"Navigation Overlay":[],"Post Type: “%s”":[],"Search results for: “%s”":[],"Responses to “%s”":[],"Response to “%s”":[],"%1$s response to “%2$s”":[],"One response to “%s”":[],"File type":[],Application:s,"image dimensions%1$s × %2$s":[],"File size":[],"unit symbolKB":[],"unit symbolMB":[],"unit symbolGB":[],"unit symbolTB":[],"unit symbolPB":[],"unit symbolEB":[],"unit symbolZB":[],"unit symbolYB":[],"unit symbolB":[],"file size%1$s %2$s":[],"File name":[],"Updating failed because you were offline. Please verify your connection and try again.":[],"Scheduling failed because you were offline. Please verify your connection and try again.":[],"Publishing failed because you were offline. Please verify your connection and try again.":[],"Font Collections":[],"Configure overlay visibility":[],"Overlay Visibility":[],"Edit overlay":[],"Edit overlay: %s":[],"No overlays found.":[],"Overlay template":[],"None (default)":[],"Error: %s":[],"Error parsing mathematical expression: %s":[],"This block contains CSS or JavaScript that will be removed when you save because you do not have permission to use unfiltered HTML.":[],"Show current breadcrumb":[],"Show home breadcrumb":[],"Value is too long.":[],"Value is too short.":[],"Value is above the maximum.":[],"Value is below the minimum.":[],"Max. columns":[],"Columns will wrap to fewer per row when they can no longer maintain the minimum width.":[],"Min. column width":[],"Includes all":[],"Is none of":[],Includes:c,"Close navigation panel":[],"Open navigation panel":[],"Custom overlay area for navigation overlays.":[],'[%1$s] Note: "%2$s"':[],"You can see all notes on this post here:":[],"resolved/reopened":[],"Email: %s":[],"Author: %1$s (IP address: %2$s, %3$s)":[],'New note on your post "%s"':[],"Email me whenever anyone posts a note":[],"Comments Page %s":[],"block descriptionThis block is deprecated. Please use the Quote block instead.":[],"block titlePullquote (deprecated)":[],"Add new reply":[],Placeholder:l,Citation:u,"It appears you are trying to use the deprecated Classic block. You can leave this block intact, or remove it entirely. Alternatively, if you have unsaved changes, you can save them and refresh to use the Classic block.":[],"Button Text":[],Filename:d,"Embed video from URL":[],"Add a background video to the cover block that will autoplay in a loop.":[],"Enter YouTube, Vimeo, or other video URL":[],"Video URL":[],"Add video":[],"This URL is not supported. Please enter a valid video link from a supported provider.":[],"Please enter a URL.":[],"Choose a media item…":[],"Choose a file…":[],"Choose a video…":[],"Show / Hide":[],"Value does not match the required pattern.":[],"Justified text can reduce readability. For better accessibility, use left-aligned text instead.":[],"Edit section":[],"Exit section":[],"Editing a section in the EditorEdit section":[],"A block pattern.":[],"Reusable design elements for your site. Create once, use everywhere.":[],Registered:f,"Enter menu name":[],"Unable to create navigation menu: %s":[],"Navigation menu created successfully.":[],Activity:p,"%s: ":[],"Row %d":[],"Insert right":[],"Insert left":[],"Executing ability…":[],"Workflow suggestions":[],"Workflow palette":[],"Open the workflow palette.":[],"Run abilities and workflows":[],"Empty.":[],"Enables custom mobile overlay design and content control for Navigation blocks, allowing you to create flexible, professional menu experiences.":[],"Customizable Navigation Overlays":[],"Enables the Workflow Palette for running workflows composed of abilities, from a unified interface.":[],"Workflow Palette":[],"Script modules to load into the import map.":[],"block descriptionDisplay content in a tabbed interface to help users navigate detailed content with ease.":[],"block titleTabs":[],"block descriptionContent for a tab in a tabbed interface.":[],"block titleTab":[],"Disconnect pattern":[],"Upload media":[],"Pick from starter content when creating a new page.":[],"All notes":[],"Unresolved notes":[],"Convert to blocks to add notes.":[],"Notes are disabled in distraction free mode.":[],"Always show starter patterns for new pages":[],"templateInactive":[],"templateActive":[],"templateActive when used":[],"More details":[],"Validating…":[],"Unknown error when running custom validation asynchronously.":[],"Validation could not be processed.":[],Valid:m,"Unknown error when running elements validation asynchronously.":[],"Could not validate elements.":[],"Tab Contents":[],"The tabs title is used by screen readers to describe the purpose and content of the tabs.":[],"Tabs Title":[],"Type / to add a block to tab":[],"Tab %d…":[],"Tab %d":[],"If toggled, this tab will be selected when the page loads.":[],"Default Tab":[],"Tab Label":[],"Add Tab":[],"Synced %s is missing. Please update or remove this link.":[],"Edit code":[],"Add custom HTML code and preview how it looks.":[],"Update and close":[],"Continue editing":[],"You have unsaved changes. What would you like to do?":[],"Unsaved changes":[],"Write JavaScript…":[],"Write CSS…":[],"Enable/disable fullscreen":[],JavaScript:h,"Edit HTML":[],"Introduce new sections and organize content to help visitors (and search engines) understand the structure of your content.":[],"Embed an X post.":[],"If this breadcrumbs block appears in a template or template part that’s shown on the homepage, enable this option to display the breadcrumb trail. Otherwise, this setting has no effect.":[],"Show on homepage":[],"Finish editing a design.":[],"The page you're looking for does not exist":[],"Route not found":[],"Warning: when you deactivate this experiment, it is best to delete all created templates except for the active ones.":[],"Allows multiple templates of the same type to be created, of which one can be active at a time.":[],"Template Activation":[],"Inline styles for editor assets.":[],"Inline scripts for editor assets.":[],"Editor styles data.":[],"Editor scripts data.":[],"Limit result set to attachments of a particular MIME type or MIME types.":[],"Limit result set to attachments of a particular media type or media types.":[],"Page %s":[],"Page not found":[],"block descriptionDisplay a custom date.":[],"block descriptionDisplays a foldable layout that groups content in collapsible sections.":[],"block descriptionContains the hidden or revealed content beneath the heading.":[],"block descriptionWraps the heading and panel in one unit.":[],"block descriptionDisplays a heading that toggles the accordion panel.":[],"Media items":[],"Search media":[],"Select Media":[],"Are you sure you want to delete this note? This will also delete all of this note's replies.":[],"Revisions (%d)":[],"paging%1$d of %2$d":[],"%d item":[],"Color Variations":[],"Shadow Type":[],"Font family to uninstall is not defined.":[],"Registered Templates":[],"Failed to create page. Please try again.":[],"%s page created successfully.":[],"Full content":[],"No content":[],"Display content":[],"The exact type of breadcrumbs shown will vary automatically depending on the page in which this block is displayed. In the specific case of a hierarchical post type with taxonomies, the breadcrumbs can either reflect its post hierarchy (default) or the hierarchy of its assigned taxonomy terms.":[],"Prefer taxonomy terms":[],"The text will resize to fit its container, resetting other font size settings.":[],"Enables a new media modal experience powered by Data Views for improved media library management.":[],"Data Views: new media modal":[],"block keywordterm title":[],"block descriptionDisplays the name of a taxonomy term.":[],"block titleTerm Name":[],"block descriptionDisplays the post count of a taxonomy term.":[],"block titleTerm Count":[],"block keywordmathematics":[],"block keywordlatex":[],"block keywordformula":[],"block descriptionDisplay mathematical notation using LaTeX.":[],"block titleMath":[],"block titleBreadcrumbs":[],"Overrides currently don't support image links. Remove the link first before enabling overrides.":[],Math:g,"CSS classes":[],"Close Notes":[],Notes:_,"View notes":[],"New note":[],"Add note":[],Reopened:v,"Marked as resolved":[],"Edit note %1$s by %2$s":[],"Reopen noteReopen":[],"Back to block":[],"Note: %s":[],"Note deleted.":[],"Note reopened.":[],"Note added.":[],"Reply added.":[],Note:y,"You are about to duplicate a bundled template. Changes will not be live until you activate the new template.":[],'Do you want to activate this "%s" template?':[],"template typeCustom":[],"Created templates":[],"Reset view":[],"Unknown error when running custom validation.":[],"No elements found":[],"Term template block display settingGrid view":[],"Term template block display settingList view":[],"Display the terms' names and number of posts assigned to each term.":[],"Name & Count":[],"Display the terms' names.":[],"When specific terms are selected, only those are displayed.":[],"When specific terms are selected, the order is based on their selection order.":[],"Selected terms":[],"Show nested terms":[],"Display terms based on specific criteria.":[],"Display terms based on the current taxonomy archive. For hierarchical taxonomies, shows children of the current term. For non-hierarchical taxonomies, shows all terms.":[],"Make term name a link":[],"Change bracket type":[],"Angle brackets":[],"Curly brackets":[],"Square brackets":[],"Round brackets":[],"No brackets":[],"e.g., x^2, \\frac{a}{b}":[],"LaTeX math syntax":[],"Set a consistent aspect ratio for all images in the gallery.":[],"All gallery images updated to aspect ratio: %s":[],"Comments block: You’re currently using the legacy version of the block. The following is just a placeholder - the final styling will likely look different. For a better representation and more customization options, switch the block to its editable mode.":[],Ancestor:b,"Source not registered":[],"Not connected":[],"No sources available":[],"Text will resize to fit its container.":[],"Fit text":[],"Allowed Blocks":[],"Specify which blocks are allowed inside this container.":[],"Select which blocks can be added inside this container.":[],"Manage allowed blocks":[],"Block hidden. You can access it via the List View (%s).":[],"Blocks hidden. You can access them via the List View (%s).":[],"Show or hide the selected block(s).":[],"Type of the comment.":[],"Creating comment failed.":[],"Comment field exceeds maximum length allowed.":[],"Creating a comment requires valid author name and email values.":[],"Invalid comment content.":[],"Cannot create a comment with that type.":[],"Sorry, you are not allowed to read this comment.":[],"Query parameter not permitted: %s":[],"Sorry, you are not allowed to read comments without a post.":[],"Sorry, this post type does not support notes.":[],"Note resolution status":[],Breadcrumbs:x,"block descriptionShow minutes required to finish reading the post. Can also show a word count.":[],"Reply to note %1$s by %2$s":[],"Reopen & Reply":[],"Original block deleted.":[],"Original block deleted. Note: %s":[],"Note date full date formatF j, Y g:i\xA0a":[],"Don't allow link notifications from other blogs (pingbacks and trackbacks) on new articles.":[],"Don't allow":[],"Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.":[],Allow:S,"Trackbacks & Pingbacks":[],"Template activation failed.":[],"Template activated.":[],"Activating template…":[],"Template Type":[],"Compatible Theme":[],Active:C,"Active templates":[],Deactivate:w,"Value must be a number.":[],"You can add custom CSS to further customize the appearance and layout of your site.":[],"Show the number of words in the post.":[],"Word Count":[],"Show minutes required to finish reading the post.":[],"Time to Read":[],"Display as range":[],"Turns reading time range display on or offDisplay as range":[],item:T,term:E,tag:D,category:O,"Suspendisse commodo lacus, interdum et.":[],"Lorem ipsum dolor sit amet, consectetur.":[],Visible:k,"Unsync and edit":[],"Synced with the selected %s.":[],"%s character":[],"Range of minutes to read%1$s–%2$s minutes":[],"block keywordtags":[],"block keywordtaxonomy":[],"block keywordterms":[],"block titleTerms Query":[],"block descriptionContains the block elements used to render a taxonomy term, like the name, description, and more.":[],"block titleTerm Template":[],Count:A,"Parent ID":[],"Term ID":[],"An error occurred while performing an update.":[],"+%s":[],"100+":[],"%s more reply":[],"Show password":[],"Hide password":[],"Date time":[],"Value must be a valid color.":[],"Open custom CSS":[],"Go to: Patterns":[],"Go to: Templates":[],"Go to: Navigation":[],"Go to: Styles":[],"Go to: Template parts":[],"Go to: %s":[],"No terms found.":[],"Term Name":[],"Limit the number of terms you want to show. To show all terms, use 0 (zero).":[],"Max terms":[],"Count, low to high":[],"Count, high to low":[],"Name: Z → A":[],"Name: A → Z":[],"If unchecked, the page will be created as a draft.":[],"Publish immediately":[],"Create a new page to add to your Navigation.":[],"Create page":[],"Edit contents":[],"The Link Relation attribute defines the relationship between a linked resource and the current document.":[],"Link relation":[],"Blog home":[],Attachment:j,Post:M,"block bindings sourceTerm Data":[],"Choose pattern":[],"Could not get a valid response from the server.":[],"Unable to connect. Please check your Internet connection.":[],"block titleAccordion":[],"block titleAccordion Panel":[],"block titleAccordion Heading":[],"block titleAccordion Item":[],"Automatically load more content as you scroll, instead of showing pagination links.":[],"Enable infinite scroll":[],"Play inline enabled because of Autoplay.":[],"Display the post type label based on the queried object.":[],"Post Type Label":[],"Show post type label":[],"Post Type: Name":[],"Accordion title":[],"Accordion content will be displayed by default.":[],"Icon Position":[],"Display a plus icon next to the accordion header.":[],"Automatically close accordions when a new one is opened.":[],"Auto-close":[],'Post Type: "%s"':[],"Add Category":[],"Add Term":[],"Add Tag":[],To:N,From:P,"Year to date":[],"Last year":[],"Month to date":[],"Last 30 days":[],"Last 7 days":[],"Past month":[],"Past week":[],Yesterday:F,Today:I,"Every value must be a string.":[],"Value must be an array.":[],"Value must be true, false, or undefined":[],"Value must be an integer.":[],"Value must be one of the elements.":[],"Value must be a valid email address.":[],"Add page":[],Optional:L,"social link block variation nameSoundCloud":[],"Display a post's publish date.":[],"Publish Date":[],'"Read more" text':[],"Poster image preview":[],"Edit or replace the poster image.":[],"Set poster image":[],"social link block variation nameYouTube":[],"social link block variation nameYelp":[],"social link block variation nameX":[],"social link block variation nameWhatsApp":[],"social link block variation nameWordPress":[],"social link block variation nameVK":[],"social link block variation nameVimeo":[],"social link block variation nameTwitter":[],"social link block variation nameTwitch":[],"social link block variation nameTumblr":[],"social link block variation nameTikTok":[],"social link block variation nameThreads":[],"social link block variation nameTelegram":[],"social link block variation nameSpotify":[],"social link block variation nameSnapchat":[],"social link block variation nameSkype":[],"social link block variation nameShare Icon":[],"social link block variation nameReddit":[],"social link block variation namePocket":[],"social link block variation namePinterest":[],"social link block variation namePatreon":[],"social link block variation nameMedium":[],"social link block variation nameMeetup":[],"social link block variation nameMastodon":[],"social link block variation nameMail":[],"social link block variation nameLinkedIn":[],"social link block variation nameLast.fm":[],"social link block variation nameInstagram":[],"social link block variation nameGravatar":[],"social link block variation nameGitHub":[],"social link block variation nameGoogle":[],"social link block variation nameGoodreads":[],"social link block variation nameFoursquare":[],"social link block variation nameFlickr":[],"social link block variation nameRSS Feed":[],"social link block variation nameFacebook":[],"social link block variation nameEtsy":[],"social link block variation nameDropbox":[],"social link block variation nameDribbble":[],"social link block variation nameDiscord":[],"social link block variation nameDeviantArt":[],"social link block variation nameCodePen":[],"social link block variation nameLink":[],"social link block variation nameBluesky":[],"social link block variation nameBehance":[],"social link block variation nameBandcamp":[],"social link block variation nameAmazon":[],"social link block variation name500px":[],"block descriptionDescribe in a few words what this site is about. This is important for search results, sharing on social media, and gives overall clarity to visitors.":[],"There is no poster image currently selected.":[],"The current poster image url is %s.":[],"Comments pagination":[],"paging
Page
%1$s
of %2$d
":[],"%1$s is in the past: %2$s":[],"%1$s between (inc): %2$s and %3$s":[],"%1$s is on or after: %2$s":[],"%1$s is on or before: %2$s":[],"%1$s is after: %2$s":[],"%1$s is before: %2$s":[],"%1$s starts with: %2$s":[],"%1$s doesn't contain: %2$s":[],"%1$s contains: %2$s":[],"%1$s is greater than or equal to: %2$s":[],"%1$s is less than or equal to: %2$s":[],"%1$s is greater than: %2$s":[],"%1$s is less than: %2$s":[],"Max.":[],"Min.":[],"The max. value must be greater than the min. value.":[],Unit:R,"Years ago":[],"Months ago":[],"Weeks ago":[],"Days ago":[],Years:z,Months:B,Weeks:V,Days:H,False:U,True:W,Over:G,"In the past":[],"Not on":[],"Between (inc)":[],"Starts with":[],"Doesn't contain":[],"After (inc)":[],"Before (inc)":[],After:K,Before:q,"Greater than or equal":[],"Less than or equal":[],"Greater than":[],"Less than":[],"%s, selected":[],"Go to the Previous Month":[],"Go to the Next Month":[],"Today, %s":[],"Date range calendar":[],"Date calendar":[],"Interactivity API: Full-page client-side navigation":[],"Set as default track":[],"Icon size":[],"Only select if the separator conveys important information and should be announced by screen readers.":[],"Sort and filter":[],"Write summary. Press Enter to expand or collapse the details.":[],"Default ()":[],"The