diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e102d46..9e4002cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** The cache now enables SQLite foreign key enforcement on every connection it prepares, and fails with `SqliteDbError::ForeignKeysUnavailable` if the setting doesn't take effect. Removing a site relies on `ON DELETE CASCADE` to clear its cached rows, so on builds where enforcement defaulted to off those rows were silently left behind. - **BREAKING:** `ShoppingCart.coupon` changed from `String` to `CouponCode`, and `ShoppingCartCostOverride.override_code` from `String` to `CostOverrideCode`, so the shopping cart and site plans describe these values with the same types. Callers will need to wrap/unwrap with `CouponCode(...)` / `CostOverrideCode(...)`. - **BREAKING:** Added a `MediaFileUnreadable { file_path }` variant to `RequestExecutionError` and `WpApiError` for a media file that exists but fails *mid-read* while its multipart body is streamed (e.g. deleted after the upload started, or a storage read error). Previously surfaced as a path-less `.genericError`; it now carries the file path and stays distinct from `MediaFileNotFound` (the file couldn't be opened at all). Both executors produce it — Swift maps a mid-read serialization failure, and Kotlin tags a file-read failure during the OkHttp upload (distinct from a socket write error) — surfacing as `WpRequestResult.MediaFileUnreadable` / `WpApiException.MediaFileUnreadable` on Kotlin. Adds a case that may affect exhaustive `match`/`when`/`switch` expressions. ([#1541](https://github.com/Automattic/wordpress-rs/issues/1541)) +- **BREAKING:** The `period` on a WordPress.com stats time series was a `String` and now has a type per endpoint — `StatsVisitsPeriod`, `StatsSubscribersPeriod` and `StatsPostViewPeriod`. None of them is a date: the shape follows the unit the caller asked for and the endpoints don't agree on it, so display them or group by them rather than reading a date out of one. Each is a record with a single `value` field. +- **BREAKING:** Date fields that were `String` are now typed: `WpDateString` for a value that can't be resolved to an instant — a bare calendar date, or a datetime in the site's timezone — and `WpGmtDateTime` for one that can. This covers `date` and `modified` across posts, pages, media, blocks, navigation, templates, template parts and every revision and autosave resource, `date` on comments, the date parameters and response dates on the WordPress.com stats endpoints, and, as GMT, `users.registered_date`, application passwords' `created` and `last_used`, and `blog_registration_date`. `WpDateString` is a record with a single `value` field rather than an alias for a string, so Swift and Kotlin see a distinct type; Rust callers construct it with `WpDateString::new(...)` and read `.value`. `CONVENTIONS.md` records how to choose between the two. +- **BREAKING:** Every way of reading a `WpGmtDateTime` now goes through one parser and accepts the same set: an RFC 3339 offset, the offsetless WordPress form (`2026-08-06T09:15:49`, read as UTC), MySQL's (`2026-08-06 09:15:49`), sub-second precision, and a unix timestamp. In Rust, `FromStr` gains the offsetless forms and loses an offset written without a colon (`+0000`), which RFC 3339 forbids and neither server was found to emit; `WpGmtDateTime::from_timestamp` is no longer public. On the bindings, lifting a `WpGmtDateTime` from a timestamp now rejects WordPress's zero date and any instant before year 1, and an out-of-range value is an error rather than silently becoming the unix epoch. +- **BREAKING:** `users.registered_date`, comments' `date_gmt`, `Subscriber.date_subscribed` and `blog_registration_date` became optional, so Swift and Kotlin see `Date?`. These endpoints format their date column without guarding WordPress's zero date, so a record that has none sends that value rather than `null`, and one such row previously failed the entire response. +- **BREAKING:** `date` on `BlockCreateParams` and `BlockUpdateParams` changed from `WpGmtDateTime` to `WpDateString`. The field is the publish date in the site's timezone, but a `WpGmtDateTime` serializes without an offset and WordPress reads an offsetless value as site-local, so passing a UTC instant published at that wall clock in the site's timezone instead. Posts, pages, media and comments already used `WpDateString` for this parameter. +- **BREAKING:** `after`, `modified_after`, `before` and `modified_before` on `NavigationListParams` changed from `String` to `WpGmtDateTime`, matching the same parameters on posts, media, comments, blocks and navigation menu items. They are now sent with an offset rather than verbatim. - Documented `GET /all-domains/` subtypes and parameters. `DomainSubtypeId::DefaultAddress` covers staging and garden subdomains as well as the free WordPress.com address, and is the set v1.1's `no_wpcom=true` excluded; v1.2 has no equivalent parameter, so clients filter this subtype out instead. - **Internal:** Corrected `GET /all-domains/` fixtures that claimed subtypes the endpoint never returns (`site_redirect`, `domain_mapping`). - Kotlin: The request executor now classifies cancelling an in-flight request via `CancellableCall.cancel()` as `CancellationError` instead of `GenericError`, matching Swift's handling of `URLError.cancelled`. Whole-call `callTimeout` expiry is classified as `HttpTimeoutError` rather than being mistaken for a cancellation, and a `CancellationException` surfacing synchronously inside the executor (e.g. from an upload callback) is classified as `CancellationError` rather than flattened into a `GenericError` ([#1492](https://github.com/Automattic/wordpress-rs/issues/1492)). @@ -46,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Swift: `WpRequestExecutor` classifies three more `URLError` codes that mean the device can't use the network right now — cellular data disallowed for the app or by carrier policy (`dataNotAllowed`, the common Wi-Fi-off case), international roaming turned off while abroad (`internationalRoamingOff`), and a voice call holding the radio on a single-radio device (`callIsActive`) — as `DeviceIsOfflineError` instead of the catch-all `GenericError`. `errorIsDeviceIsOffline` previously matched only `notConnectedToInternet` and `networkConnectionLost`, so these fell through and a caller wanting "we're offline" had to match `GenericError`, which also covers unrelated failures. This matches what Kotlin callers effectively get through the `NetworkAvailabilityProvider` gate. ([#1501](https://github.com/Automattic/wordpress-rs/issues/1501)) - Swift: `WpRequestExecutor` classifies a URLSession timeout (`URLError.timedOut`) as `HttpTimeoutError` instead of the catch-all `GenericError`. The timeout had no branch in the executor's error dispatch, so `HttpTimeoutError` was unreachable on Apple platforms and a caller wanting "retry on timeout" had to match `GenericError`, which also covers unrelated failures. This brings Swift to parity with reqwest (`is_timeout()`) and Kotlin (`SocketTimeoutException`). ([#1491](https://github.com/Automattic/wordpress-rs/issues/1491)) - Swift: Large (>10 MB) multipart uploads leaked their temporary file. A form estimated over 10 MB (or built with `forceWriteToFile: true`) is serialized to a UUID-named temp file under `FileManager.default.temporaryDirectory` and handed to `URLSession.uploadTask(fromFile:)`, which treats the file as caller-owned — it reads it during the transfer but never deletes it — so every large media upload (4K video, ProRAW, multi-file posts) left a temp file behind for the OS to reclaim later. `upload(...)` now removes the file once the transfer completes, on success, failure, or cancellation. ([#1540](https://github.com/Automattic/wordpress-rs/issues/1540)) +- A datetime WordPress never set (`0000-00-00 00:00:00`, and PHP's renderings of it) is no longer read as an instant in 1 BCE. Fields that can be unset now report it as absent; everywhere else it is a parse error rather than a plausible-looking date. +- A cached post or media item whose stored `modified_gmt` could not be read was treated as up to date and never refetched, so the stale copy stayed in the cache indefinitely. The staleness check dropped such rows silently, and a row missing from that lookup means "not stale"; an unreadable timestamp now marks the item stale instead. - Swift: `WpRequestExecutor.sleep(millis:)` converted milliseconds to nanoseconds with the wrong factor (`* 1_000` instead of `* 1_000_000`), so it slept 1000× too short — a `Retry-After: 30` waited 30 ms instead of 30 s. `RetryAfterMiddleware` then re-sent immediately, the server kept returning 429, and after `max_retries` the caller observed `MisconfiguredRateLimitError` where honoring the backoff would usually have succeeded. The executor now waits the full interval, and no longer risks a `fatalError` if the sleep's task is cancelled. - Swift: Classify invalid-SSL failures from the failed handshake's `SecTrust` (`URLError.failureURLPeerTrust`), via `SecTrustCopyCertificateChain`, instead of reading the undocumented `NSErrorPeerCertificateChainKey` `userInfo` string that has no public constant. Behavior is unchanged on every platform: iOS/macOS/tvOS still surface the presented certificate as `certificateNotValidForName`, and watchOS — which exposes no peer trust — still degrades to `genericSslError`. ([#1510](https://github.com/Automattic/wordpress-rs/issues/1510)) - `isSiteUnreachable` now returns the same answer for a refused connection — the host resolves, but nothing is listening (server down, wrong port) — on every executor. Previously it was `NonExistentSiteError` on Swift (so `isSiteUnreachable` was `true`) but the generic `HttpError` on Kotlin and reqwest (so it was `false`); a refused connection is now a `ConnectionError` everywhere, which `isSiteUnreachable` covers. `NonExistentSiteError` is reserved for a DNS-resolution failure. diff --git a/CLAUDE.md b/CLAUDE.md index 04a776a5f..0da4bfc81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ Test credentials are configured in: ## Important Files +- `CONVENTIONS.md` - Rules for modelling API responses; read before adding types - `Makefile` - Build automation and platform-specific targets - `wp_api/src/lib.rs` - Main library entry point - `wp_api/src/request.rs` - Core request/response handling diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 000000000..6d766d51e --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,33 @@ +# Conventions + +Rules for modelling the WordPress and WordPress.com REST APIs in this crate, so the same decisions don't get re-argued on every endpoint. + +## Types + +A type is a promise. Two values share one only when they are the same thing — not nearly, not for now. If you can't say yes without a qualifier, the answer is no. "Same shape today", "same apart from one endpoint", "same unless the server does X" are all no. + +Share helpers where they genuinely overlap; that's what the overlap is good for. + +## Dates + +When you model a point in time, reach for one of these two types rather than a bare `String`. + +**`WpGmtDateTime`** — the value resolves to an absolute instant, because it is UTC or carries an offset. The bindings lower it to a unix timestamp, so a value that isn't genuinely an instant becomes a wrong one. + +**`WpDateString`** — it denotes a date but can't be resolved to an instant: a bare calendar date (`2026-08-06`), or a datetime in the *site's* timezone (`2026-08-06 09:15:49`), which needs the site's offset to place. + +Where an endpoint sends both forms of the same timestamp, model both — the GMT one as `WpGmtDateTime`, its local twin as `WpDateString`. + +Every way of reading a `WpGmtDateTime` accepts the same set: an offset-bearing value, the offsetless WordPress form, MySQL's, and a unix timestamp. The offsetless forms are read as UTC, so only reach for this type once you know the value is GMT. + +Decide from what the endpoint implementation produces, not from the field's name or the schema's wording. Both directions bite: a "most active day" is an instant, because it comes off a comment's GMT timestamp, while a "last updated" can be prose for display. + +Query parameters are no different. `/wp/v2`'s `after` is documented as ISO-8601 and matched against the site-local `post_date` column, but `WP_Date_Query` converts an offset-bearing value into the site's timezone first, so `WpGmtDateTime` is right for it. + +### Absent and unparseable values + +`wp_com`'s domain fields send boolean `false` rather than `null` when a date doesn't apply; `deserialize_optional_date_string` covers that for `WpDateString`. `deserialize_optional_wp_gmt_date_time` treats `null` and `""` as absent. + +WordPress's zero date — `0000-00-00 00:00:00`, and what PHP's formatters make of it — is not a datetime. `deserialize_optional_wp_gmt_date_time` reads it as `None`; everywhere else it is an error, because the alternative is an instant in 1 BCE that looks like data. + +So a field on an endpoint that doesn't guard the column has to be an `Option` — otherwise one such record fails the entire response. `/wp/v2` guards posts, but not users or comments. diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/applicationpasswords/ApplicationPasswordListScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/applicationpasswords/ApplicationPasswordListScreen.kt index b2800f43b..98b6ff174 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/applicationpasswords/ApplicationPasswordListScreen.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/applicationpasswords/ApplicationPasswordListScreen.kt @@ -61,7 +61,7 @@ fun ApplicationPasswordListScreen( items(applicationPasswords) { appPassword -> ListItem( headlineContent = { Text(appPassword.name) }, - supportingContent = { Text(appPassword.created) } + supportingContent = { Text(appPassword.created.toString()) } ) } } diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionViewModel.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionViewModel.kt index eb8e3600f..52addee91 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionViewModel.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postcollection/PostCollectionViewModel.kt @@ -177,8 +177,8 @@ class PostCollectionViewModel( title = fullEntity.data.title?.rendered ?: "", contentPreview = fullEntity.data.content.rendered.take(100), status = fullEntity.data.status.toString(), - date = fullEntity.data.date, - modified = fullEntity.data.modified, + date = fullEntity.data.date.value, + modified = fullEntity.data.modified.value, author = fullEntity.data.author?.toString() ) } diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/posts/PostListByTypeScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/posts/PostListByTypeScreen.kt index 0e5abf11c..3935531d8 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/posts/PostListByTypeScreen.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/posts/PostListByTypeScreen.kt @@ -89,7 +89,7 @@ fun PostListByTypeScreen( ListItem( headlineContent = { Text(post.title?.rendered ?: "(untitled)") }, supportingContent = { Text(post.status.toString()) }, - overlineContent = { Text(post.date) } + overlineContent = { Text(post.date.value) } ) } if (error != null) { diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt index 45d44a19d..48dd82b56 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt @@ -104,8 +104,8 @@ class StressTestViewModel( contentPreview = fullEntity.data.content.rendered.take(100), status = fullEntity.data.status.toString(), author = fullEntity.data.author?.toString(), - date = fullEntity.data.date, - modified = fullEntity.data.modified + date = fullEntity.data.date.value, + modified = fullEntity.data.modified.value ) } diff --git a/native/swift/Example/Example/ListViewData.swift b/native/swift/Example/Example/ListViewData.swift index bd2b2dafe..c2f675dc1 100644 --- a/native/swift/Example/Example/ListViewData.swift +++ b/native/swift/Example/Example/ListViewData.swift @@ -88,11 +88,7 @@ extension PluginWithEditContext: ListViewDataConvertable { extension ApplicationPasswordWithEditContext: ListViewDataConvertable { var creationDateString: String { - guard let date = Date.fromWordPressDate(self.created) else { - return self.created - } - - return RelativeDateTimeFormatter().string(for: date) ?? self.created + RelativeDateTimeFormatter().string(for: self.created) ?? self.created.formatted() } var asListViewData: ListViewData { diff --git a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift index 760feba26..18266a384 100644 --- a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift +++ b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift @@ -11,8 +11,13 @@ public extension Date { return dateFormatter }() - /// Parses a date string provided by WordPress APIs (which are assumed to be in GMT) + /// Parses an offsetless WordPress datetime string as GMT — the shape of + /// WordPress's `_gmt` fields. /// + /// Those fields cross the bindings as `Date`, so this is for a string + /// obtained some other way. It is the wrong tool for `date` and + /// `modified`, which are in the site's timezone rather than GMT: reading + /// them with this shifts the instant by the site's UTC offset. static func fromWordPressDate(_ string: String) -> Date? { wordpressDateFormatter.date(from: string) } diff --git a/wp_api/src/application_passwords.rs b/wp_api/src/application_passwords.rs index cd300b213..04d3f6f6a 100644 --- a/wp_api/src/application_passwords.rs +++ b/wp_api/src/application_passwords.rs @@ -1,5 +1,6 @@ use std::fmt::Display; +use crate::date::WpGmtDateTime; use serde::{Deserialize, Serialize}; use wp_contextual::WpContextual; @@ -12,10 +13,10 @@ pub struct SparseApplicationPassword { #[WpContext(edit, embed, view)] pub name: Option, #[WpContext(edit, view)] - pub created: Option, + pub created: Option, #[WpContextualOption] #[WpContext(edit, view)] - pub last_used: Option, + pub last_used: Option, #[WpContextualOption] #[WpContext(edit, view)] pub last_ip: Option, diff --git a/wp_api/src/block_revisions.rs b/wp_api/src/block_revisions.rs index ec850a71b..e6b2f5426 100644 --- a/wp_api/src/block_revisions.rs +++ b/wp_api/src/block_revisions.rs @@ -1,7 +1,7 @@ use crate::{ UserId, WpApiParamOrder, blocks::BlockId, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, url_query::{ AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap, @@ -76,11 +76,11 @@ pub struct SparseBlockRevision { #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/blocks.rs b/wp_api/src/blocks.rs index 7e683c6b7..5ef39bed1 100644 --- a/wp_api/src/blocks.rs +++ b/wp_api/src/blocks.rs @@ -1,6 +1,6 @@ use crate::{ WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, url_query::{ AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap, @@ -47,7 +47,7 @@ impl_as_query_value_from_to_string!(BlockStatus); #[derive(Debug, Serialize, Deserialize, WpContextual)] pub struct SparseBlock { #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] @@ -58,7 +58,7 @@ pub struct SparseBlock { #[WpContext(edit, embed, view)] pub link: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] @@ -201,7 +201,7 @@ pub struct BlockCreateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -237,7 +237,7 @@ pub struct BlockUpdateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/wp_api/src/comments.rs b/wp_api/src/comments.rs index 875da3c89..2e625af79 100644 --- a/wp_api/src/comments.rs +++ b/wp_api/src/comments.rs @@ -1,6 +1,6 @@ use crate::{ UserAvatarSize, UserId, WpAdditionalFields, WpApiParamOrder, WpResponseString, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, posts::PostId, url_query::{ @@ -215,7 +215,7 @@ pub struct CommentCreateParams { /// The date the comment was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the comment was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -290,7 +290,7 @@ impl CommentCreateParamsBuilder { self.params.author_user_agent = author_user_agent; self } - pub fn date(mut self, date: Option) -> Self { + pub fn date(mut self, date: Option) -> Self { self.params.date = date; self } @@ -348,7 +348,7 @@ pub struct CommentUpdateParams { /// The date the comment was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the comment was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -388,8 +388,16 @@ pub struct SparseComment { #[WpContextualField] pub content: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, + /// The date the comment was published, as GMT. `None` when it was never + /// set: this endpoint formats the column without guarding it, so a comment + /// with no date arrives as WordPress's zero date rather than `null`. #[WpContext(edit, view)] + #[WpContextualOption] + #[serde( + default, + deserialize_with = "crate::date::deserialize_optional_wp_gmt_date_time" + )] pub date_gmt: Option, #[WpContext(edit, embed, view)] pub link: Option, @@ -482,6 +490,20 @@ mod tests { }; use rstest::*; + /// `/wp/v2/comments` formats `comment_date_gmt` without guarding the + /// zero date, so a comment that has none arrives as that value rather + /// than `null`. It has to read as absent — anything else fails the whole + /// page, taking every other comment in the response with it. + #[rstest] + #[case::never_set(r#""-0001-11-30T00:00:00""#)] + #[case::null("null")] + #[case::empty_string(r#""""#)] + fn test_comment_date_gmt_reads_as_absent(#[case] date_gmt: &str) { + let json = format!(r#"{{"date_gmt": {date_gmt}}}"#); + let comment: SparseComment = serde_json::from_str(&json).expect("Unable to parse JSON"); + assert_eq!(comment.date_gmt, None); + } + #[rstest] #[case(CommentListParams::default(), "")] #[case(generate!(CommentListParams, (page, Some(2))), "page=2")] diff --git a/wp_api/src/date.rs b/wp_api/src/date.rs index 223a0fb46..69b5e593d 100644 --- a/wp_api/src/date.rs +++ b/wp_api/src/date.rs @@ -1,13 +1,22 @@ +use crate::impl_as_query_value_from_to_string; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{fmt::Display, str::FromStr}; -use wp_serde_helper::wp_utc_date_format; +use wp_serde_helper::{WpDateTimeParseError, parse_wp_date_time, wp_utc_date_format}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct WpGmtDateTime(#[serde(with = "wp_utc_date_format")] pub DateTime); impl WpGmtDateTime { - pub fn from_timestamp(seconds: i64) -> Self { + /// Build an instant from a timestamp that did not come from WordPress, and + /// so is not held to WordPress's rules — an X.509 certificate's validity + /// bounds, for instance. Falls back to the unix epoch if the value is out + /// of range. + /// + /// A timestamp arriving *from* WordPress or across the bindings goes + /// through [`wp_serde_helper::wp_date_time_from_timestamp`], which rejects + /// the zero date rather than resolving it. + pub(crate) fn from_unchecked_timestamp(seconds: i64) -> Self { let date_time = DateTime::::from_timestamp(seconds, 0).unwrap_or(DateTime::::UNIX_EPOCH); Self(date_time) @@ -15,10 +24,10 @@ impl WpGmtDateTime { } impl FromStr for WpGmtDateTime { - type Err = chrono::ParseError; + type Err = WpDateTimeParseError; fn from_str(s: &str) -> Result { - s.parse::>().map(Self) + parse_wp_date_time(s).map(Self) } } @@ -30,26 +39,42 @@ impl Display for WpGmtDateTime { uniffi::custom_type!(WpGmtDateTime, i64, { lower: |date_time| date_time.0.timestamp(), - try_lift: |seconds| Ok(WpGmtDateTime::from_timestamp(seconds)), + try_lift: |seconds| Ok(wp_serde_helper::wp_date_time_from_timestamp(seconds).map(WpGmtDateTime)?), }); -uniffi::custom_newtype!(WpDateString, String); -/// A date string in `"YYYY-MM-DD"` format as returned by some WordPress.com -/// API fields (e.g. domain expiry, registration date). +/// A date the API sends as a string that can't be resolved to an instant — +/// either because it carries no time (`"2026-08-06"`, e.g. domain expiry) or +/// because its time is in the site's timezone rather than GMT +/// (`"2026-08-06 09:15:49"`). +/// +/// Use [`WpGmtDateTime`] instead wherever the API gives a GMT or +/// offset-bearing value. /// /// Some PHP endpoints return `false` instead of `null` when a date is not /// applicable. Use [`deserialize_optional_date_string`] on fields that /// exhibit this pattern. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] #[serde(transparent)] -pub struct WpDateString(pub String); +pub struct WpDateString { + pub value: String, +} + +impl WpDateString { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} impl Display for WpDateString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + write!(f, "{}", self.value) } } +impl_as_query_value_from_to_string!(WpDateString); + /// Deserialize an `Option` that may be a string, `null`, or /// boolean `false` (a common PHP pattern for "not applicable"). pub fn deserialize_optional_date_string<'de, D>( @@ -59,27 +84,46 @@ where D: serde::Deserializer<'de>, { wp_serde_helper::deserialize_false_or_string_or_null(deserializer) - .map(|opt| opt.map(WpDateString)) + .map(|opt| opt.map(WpDateString::new)) } -/// Deserialize an `Option` where the API may send an ISO-8601 -/// datetime string, `null`, or an empty string `""`. +/// Deserialize an `Option` for a field that may be unset. /// -/// Some WordPress.com endpoints return `""` (or omit the field) instead of -/// `null` when a datetime is not set; both map to `None`. Populated values are -/// parsed by [`WpGmtDateTime`]'s ISO-8601 handling (which accepts a timezone -/// offset such as `+00:00`). +/// `null`, an empty string, and WordPress's zero date read as `None`, as +/// does an absent field on a member carrying `#[serde(default)]`. A populated +/// value accepts every form [`WpGmtDateTime`] does, including a bare timestamp +/// sent as a JSON number; anything else is an error. pub fn deserialize_optional_wp_gmt_date_time<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { - match Option::::deserialize(deserializer)? { - Some(s) if !s.trim().is_empty() => WpGmtDateTime::from_str(&s) - .map(Some) - .map_err(serde::de::Error::custom), - _ => Ok(None), + /// The two shapes a populated value arrives in, matching what the + /// non-optional path accepts. + #[derive(Deserialize)] + #[serde(untagged)] + enum Populated { + String(String), + Timestamp(i64), + } + + let parsed = match Option::::deserialize(deserializer)? { + Some(Populated::String(s)) if !s.trim().is_empty() => { + WpGmtDateTime::from_str(&s).map_err(|e| (e, s)) + } + Some(Populated::Timestamp(seconds)) => { + wp_serde_helper::wp_date_time_from_timestamp(seconds) + .map(WpGmtDateTime) + .map_err(|e| (e, seconds.to_string())) + } + _ => return Ok(None), + }; + + match parsed { + Ok(date_time) => Ok(Some(date_time)), + Err((WpDateTimeParseError::NotSet, _)) => Ok(None), + Err((e, value)) => Err(serde::de::Error::custom(format!("{e}: {value}"))), } } @@ -137,8 +181,82 @@ mod tests { #[case::year_3000(32503680000, "3000-01-01T00:00:00+00:00")] fn test_gmt_date_time_from_time_stamp(#[case] seconds: i64, #[case] expected_date_str: &str) { assert_eq!( - WpGmtDateTime::from_timestamp(seconds).0.to_rfc3339(), + WpGmtDateTime::from_unchecked_timestamp(seconds) + .0 + .to_rfc3339(), expected_date_str ); } + + /// The bindings lift a `WpGmtDateTime` from a timestamp, so that lift is a + /// parse boundary and holds to the same rules as every other read. + #[rstest] + #[case::never_set(-62169984000)] + #[case::before_year_one(-62200000000)] + fn test_lifting_an_impossible_timestamp_is_rejected(#[case] seconds: i64) { + assert!(wp_serde_helper::wp_date_time_from_timestamp(seconds).is_err()); + } + + #[rstest] + #[case::offset("2026-08-06T09:15:49+00:00")] + #[case::offsetless("2026-08-06T09:15:49")] + #[case::mysql("2026-08-06 09:15:49")] + #[case::sub_second("2026-08-06T09:15:49.000000")] + #[case::unix_timestamp("1786007749")] + fn test_gmt_date_time_from_str_accepts_every_form_serde_does(#[case] value: &str) { + let parsed = value + .parse::() + .expect("Every form the serde path accepts should parse"); + assert_eq!(parsed.0.to_rfc3339(), "2026-08-06T09:15:49+00:00"); + + let via_serde: WpGmtDateTime = serde_json::from_value(serde_json::json!(value)) + .expect("The serde path should accept it too"); + assert_eq!(via_serde, parsed); + } + + #[derive(serde::Deserialize)] + struct OptionalGmtDateTime { + #[serde(default, deserialize_with = "deserialize_optional_wp_gmt_date_time")] + value: Option, + } + + #[rstest] + #[case::offset(r#"{"value": "2026-08-06T09:15:49+00:00"}"#)] + #[case::offsetless(r#"{"value": "2026-08-06T09:15:49"}"#)] + #[case::mysql(r#"{"value": "2026-08-06 09:15:49"}"#)] + fn test_deserialize_optional_wp_gmt_date_time(#[case] json: &str) { + let parsed: OptionalGmtDateTime = + serde_json::from_str(json).expect("Test case should be a valid JSON"); + assert_eq!( + parsed.value.expect("present").0.to_rfc3339(), + "2026-08-06T09:15:49+00:00" + ); + } + + /// The optional path accepts the same set as the non-optional one. The + /// timestamp cases are the ones that diverged: it read the value as a + /// string, so a bare JSON number errored where `WpGmtDateTime`'s own + /// `Deserialize` accepted it. + #[rstest] + #[case::timestamp_as_number(r#"{"value": 1786007749}"#)] + #[case::timestamp_as_string(r#"{"value": "1786007749"}"#)] + #[case::sub_second(r#"{"value": "2026-08-06T09:15:49.000000"}"#)] + fn test_deserialize_optional_wp_gmt_date_time_matches_the_required_path(#[case] json: &str) { + let parsed: OptionalGmtDateTime = + serde_json::from_str(json).expect("Test case should be a valid JSON"); + assert_eq!( + parsed.value.expect("present").0.to_rfc3339(), + "2026-08-06T09:15:49+00:00" + ); + } + + #[rstest] + #[case::empty_string(r#"{"value": ""}"#)] + #[case::null(r#"{"value": null}"#)] + #[case::absent(r#"{}"#)] + fn test_deserialize_optional_wp_gmt_date_time_absent(#[case] json: &str) { + let parsed: OptionalGmtDateTime = + serde_json::from_str(json).expect("Test case should be a valid JSON"); + assert!(parsed.value.is_none()); + } } diff --git a/wp_api/src/global_styles_revisions.rs b/wp_api/src/global_styles_revisions.rs index 571483062..46657dfe5 100644 --- a/wp_api/src/global_styles_revisions.rs +++ b/wp_api/src/global_styles_revisions.rs @@ -1,6 +1,6 @@ use crate::{ JsonValue, UserId, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, global_styles::GlobalStylesId, url_query::{ AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap, @@ -36,11 +36,11 @@ pub struct SparseGlobalStylesRevision { #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/media.rs b/wp_api/src/media.rs index cb02eb089..3eda94a63 100644 --- a/wp_api/src/media.rs +++ b/wp_api/src/media.rs @@ -1,6 +1,6 @@ use crate::{ UserId, WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, posts::{ PostCommentStatus, PostId, PostPingStatus, PostStatus, WpApiParamPostsOrderBy, @@ -190,7 +190,7 @@ pub struct MediaUpdateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -257,7 +257,7 @@ pub struct MediaCreateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -338,7 +338,7 @@ impl From for HashMap { map.insert(k.to_string(), v); } }; - add("date", params.date); + add("date", params.date.map(|d| d.to_string())); add("date_gmt", params.date_gmt.map(|d| d.to_string())); add("slug", params.slug); add("status", params.status.map(|x| x.to_string())); @@ -363,7 +363,7 @@ pub struct SparseMedia { #[WpContext(edit, embed, view)] pub id: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] @@ -372,7 +372,7 @@ pub struct SparseMedia { #[WpContext(edit, embed, view)] pub link: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/nav_menu_item_revisions.rs b/wp_api/src/nav_menu_item_revisions.rs index b90f0e13d..3eb1faf36 100644 --- a/wp_api/src/nav_menu_item_revisions.rs +++ b/wp_api/src/nav_menu_item_revisions.rs @@ -1,6 +1,6 @@ use crate::{ UserId, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, nav_menu_items::{NavMenuItemId, NavMenuItemStatus, NavMenuItemType}, nav_menus::NavMenuId, wp_content_i64_id, @@ -15,7 +15,7 @@ pub struct SparseNavMenuItemRevision { #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] @@ -24,7 +24,7 @@ pub struct SparseNavMenuItemRevision { #[WpContext(edit, embed, view)] pub id: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/navigation_revisions.rs b/wp_api/src/navigation_revisions.rs index 2119222a0..ecaef189a 100644 --- a/wp_api/src/navigation_revisions.rs +++ b/wp_api/src/navigation_revisions.rs @@ -1,6 +1,6 @@ use crate::{ UserId, WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, navigations::NavigationId, url_query::{ @@ -81,11 +81,11 @@ pub struct SparseNavigationRevision { #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/navigations.rs b/wp_api/src/navigations.rs index 5db07eb61..fb61f9533 100644 --- a/wp_api/src/navigations.rs +++ b/wp_api/src/navigations.rs @@ -1,6 +1,6 @@ use crate::{ WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, url_query::{ AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap, @@ -47,7 +47,7 @@ impl_as_query_value_from_to_string!(NavigationStatus); #[derive(Debug, Serialize, Deserialize, WpContextual)] pub struct SparseNavigation { #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] @@ -58,7 +58,7 @@ pub struct SparseNavigation { #[WpContext(edit, embed, view)] pub link: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] @@ -130,16 +130,16 @@ pub struct NavigationListParams { pub search: Option, /// Limit response to posts published after a given ISO8601 compliant date. #[uniffi(default = None)] - pub after: Option, + pub after: Option, /// Limit response to posts modified after a given ISO8601 compliant date. #[uniffi(default = None)] - pub modified_after: Option, + pub modified_after: Option, /// Limit response to posts published before a given ISO8601 compliant date. #[uniffi(default = None)] - pub before: Option, + pub before: Option, /// Limit response to posts modified before a given ISO8601 compliant date. #[uniffi(default = None)] - pub modified_before: Option, + pub modified_before: Option, /// Ensure result set excludes specific IDs. #[uniffi(default = [])] pub exclude: Vec, @@ -219,7 +219,7 @@ pub struct NavigationUpdateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -255,7 +255,7 @@ pub struct NavigationCreateParams { /// The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, /// The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/wp_api/src/post_revisions.rs b/wp_api/src/post_revisions.rs index 62f84fcb0..a21a5bad8 100644 --- a/wp_api/src/post_revisions.rs +++ b/wp_api/src/post_revisions.rs @@ -1,6 +1,6 @@ use crate::{ UserId, WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, posts::PostId, url_query::{ @@ -82,11 +82,11 @@ pub struct SparseAnyPostRevision { #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/posts.rs b/wp_api/src/posts.rs index 6f28513f4..e716632f1 100644 --- a/wp_api/src/posts.rs +++ b/wp_api/src/posts.rs @@ -1,6 +1,6 @@ use crate::{ JsonValue, UserId, WpAdditionalFields, WpApiParamOrder, - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, impl_as_query_value_from_to_string, media::MediaId, terms::TermId, @@ -185,7 +185,7 @@ pub struct PostCreateParams { // The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, // The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -279,7 +279,7 @@ pub struct PostUpdateParams { // The date the post was published, in the site's timezone. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, + pub date: Option, // The date the post was published, as GMT. #[uniffi(default = None)] #[serde(skip_serializing_if = "Option::is_none")] @@ -375,7 +375,7 @@ pub struct SparseAnyPost { #[WpContext(edit, embed, view)] pub id: Option, #[WpContext(edit, embed, view)] - pub date: Option, + pub date: Option, #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] @@ -384,7 +384,7 @@ pub struct SparseAnyPost { #[WpContext(edit, embed, view)] pub link: Option, #[WpContext(edit, view)] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view)] pub modified_gmt: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/ssl.rs b/wp_api/src/ssl.rs index 0d3698e31..b65775f2b 100644 --- a/wp_api/src/ssl.rs +++ b/wp_api/src/ssl.rs @@ -127,7 +127,7 @@ pub struct SSLCertificateIssuer { impl From for WpGmtDateTime { fn from(date_time: x509_cert::time::Time) -> Self { - WpGmtDateTime::from_timestamp(date_time.to_unix_duration().as_secs() as i64) + WpGmtDateTime::from_unchecked_timestamp(date_time.to_unix_duration().as_secs() as i64) } } diff --git a/wp_api/src/template_autosaves.rs b/wp_api/src/template_autosaves.rs index 86efc368f..3936bf12b 100644 --- a/wp_api/src/template_autosaves.rs +++ b/wp_api/src/template_autosaves.rs @@ -1,5 +1,6 @@ use crate::{ UserId, + date::WpDateString, posts::PostId, templates::{ SparseTemplateContentWrapper, SparseTemplateTitleWrapper, TemplateId, TemplateStatus, @@ -49,9 +50,9 @@ pub struct SparseTemplateAutosave { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view, embed)] pub is_custom: Option, #[WpContext(edit, view, embed)] diff --git a/wp_api/src/template_part_autosaves.rs b/wp_api/src/template_part_autosaves.rs index 93be71a27..d8e477f73 100644 --- a/wp_api/src/template_part_autosaves.rs +++ b/wp_api/src/template_part_autosaves.rs @@ -1,5 +1,6 @@ use crate::{ UserId, + date::WpDateString, posts::PostId, template_parts::TemplatePartId, templates::{SparseTemplateContentWrapper, SparseTemplateTitleWrapper, TemplateStatus}, @@ -48,9 +49,9 @@ pub struct SparseTemplatePartAutosave { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, embed, view)] pub area: Option, #[WpContext(edit, embed, view)] diff --git a/wp_api/src/template_part_revisions.rs b/wp_api/src/template_part_revisions.rs index 74ed8c9ac..24682de1b 100644 --- a/wp_api/src/template_part_revisions.rs +++ b/wp_api/src/template_part_revisions.rs @@ -1,5 +1,7 @@ use crate::{ - UserId, WpApiParamOrder, impl_as_query_value_from_to_string, + UserId, WpApiParamOrder, + date::WpDateString, + impl_as_query_value_from_to_string, posts::PostId, template_parts::TemplatePartId, templates::{SparseTemplateContentWrapper, SparseTemplateTitleWrapper, TemplateStatus}, @@ -112,9 +114,9 @@ pub struct SparseTemplatePartRevision { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, embed, view)] pub area: Option, // Revision-specific: the wp_id of the parent template part post diff --git a/wp_api/src/template_parts.rs b/wp_api/src/template_parts.rs index c663633ed..ba2d07043 100644 --- a/wp_api/src/template_parts.rs +++ b/wp_api/src/template_parts.rs @@ -1,5 +1,6 @@ use crate::{ UserId, + date::WpDateString, post_types::PostType, posts::PostId, templates::{ @@ -67,9 +68,9 @@ pub struct SparseTemplatePart { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, embed, view)] pub area: Option, } diff --git a/wp_api/src/template_revisions.rs b/wp_api/src/template_revisions.rs index 591a4f436..9f26a5802 100644 --- a/wp_api/src/template_revisions.rs +++ b/wp_api/src/template_revisions.rs @@ -1,5 +1,7 @@ use crate::{ - UserId, WpApiParamOrder, impl_as_query_value_from_to_string, + UserId, WpApiParamOrder, + date::WpDateString, + impl_as_query_value_from_to_string, posts::PostId, templates::{ SparseTemplateContentWrapper, SparseTemplateTitleWrapper, TemplateId, TemplateStatus, @@ -113,9 +115,9 @@ pub struct SparseTemplateRevision { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view, embed)] pub is_custom: Option, #[WpContext(edit, view, embed)] diff --git a/wp_api/src/templates.rs b/wp_api/src/templates.rs index 08610a9ea..c99de60aa 100644 --- a/wp_api/src/templates.rs +++ b/wp_api/src/templates.rs @@ -1,5 +1,7 @@ use crate::{ - UserId, impl_as_query_value_from_to_string, + UserId, + date::WpDateString, + impl_as_query_value_from_to_string, post_types::PostType, posts::PostId, url_query::{ @@ -123,9 +125,9 @@ pub struct SparseTemplate { #[WpContextualOption] #[serde( default, - deserialize_with = "wp_serde_helper::deserialize_false_or_string" + deserialize_with = "crate::date::deserialize_optional_date_string" )] - pub modified: Option, + pub modified: Option, #[WpContext(edit, view, embed)] pub is_custom: Option, #[WpContext(edit, view, embed)] diff --git a/wp_api/src/unit_test_common.rs b/wp_api/src/unit_test_common.rs index add9f3ac2..01ec3f669 100644 --- a/wp_api/src/unit_test_common.rs +++ b/wp_api/src/unit_test_common.rs @@ -31,7 +31,7 @@ where #[cfg(test)] pub fn unit_test_example_date_as_option() -> Option { Some( - "2024-02-09T02:14:13+0000" + "2024-02-09T02:14:13+00:00" .parse::() .expect("Example date is parseable"), ) diff --git a/wp_api/src/users.rs b/wp_api/src/users.rs index 2edcd7e1e..a259ca79b 100644 --- a/wp_api/src/users.rs +++ b/wp_api/src/users.rs @@ -1,5 +1,6 @@ use crate::{ EnumFromStrParsingError, JsonValue, OptionFromStr, WpApiParamOrder, WpResponseString, + date::WpGmtDateTime, impl_as_query_value_from_to_string, url_query::{ AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap, @@ -502,8 +503,17 @@ pub struct SparseUser { pub nickname: Option, #[WpContext(edit, embed, view)] pub slug: Option, + /// When the account was registered. `None` when it was never set: this + /// endpoint formats the column without guarding it, so an account that has + /// no registration date arrives as WordPress's zero date rather than + /// `null`. #[WpContext(edit)] - pub registered_date: Option, + #[WpContextualOption] + #[serde( + default, + deserialize_with = "crate::date::deserialize_optional_wp_gmt_date_time" + )] + pub registered_date: Option, #[WpContext(edit)] pub roles: Option>, #[WpContext(edit)] @@ -527,6 +537,20 @@ mod tests { }; use rstest::*; + /// `/wp/v2/users` formats `user_registered` without guarding the never-set + /// date, so an account that has none arrives as that value rather than + /// `null`. It has to read as absent — anything else fails the whole page, + /// taking every other user in the response with it. + #[rstest] + #[case::never_set(r#""-001-11-30T00:00:00+00:00""#)] + #[case::null("null")] + #[case::empty_string(r#""""#)] + fn test_user_registered_date_reads_as_absent(#[case] registered_date: &str) { + let json = format!(r#"{{"registered_date": {registered_date}}}"#); + let user: SparseUser = serde_json::from_str(&json).expect("Unable to parse JSON"); + assert_eq!(user.registered_date, None); + } + #[rstest] #[case(UserListParams::default(), "")] #[case(generate!(UserListParams, (page, Some(1))), "page=1")] diff --git a/wp_api/src/wp_com/domains.rs b/wp_api/src/wp_com/domains.rs index 5c578ea57..1ecf7c463 100644 --- a/wp_api/src/wp_com/domains.rs +++ b/wp_api/src/wp_com/domains.rs @@ -1005,7 +1005,9 @@ pub struct SiteDomain { pub domain_registration_agreement_url: Option, /// Google Workspace email subscription, if configured. pub google_apps_subscription: Option, - /// Time when domain registration becomes pending, if applicable. + /// How long a pending registration is expected to take, phrased for + /// display. Despite the name this is a duration, not a point in time, so + /// it is not a date. pub pending_registration_time: Option, /// Whether a WHOIS update is pending. pub pending_whois_update: Option, @@ -1099,7 +1101,7 @@ mod tests { // Date-only `"YYYY-MM-DD"` expiry, the real-world format from the API. assert_eq!( registration.expiry, - Some(WpDateString("2027-01-01".to_string())) + Some(WpDateString::new("2027-01-01".to_string())) ); // The API returns `subscription_id` as a string, not a number. assert_eq!(registration.subscription_id, Some(SubscriptionId(55555))); @@ -1691,14 +1693,14 @@ mod tests { assert_eq!(registered.expired, Some(false)); assert_eq!( registered.expiry, - Some(WpDateString("2027-03-15".to_string())) + Some(WpDateString::new("2027-03-15".to_string())) ); assert_eq!(registered.expiry_soon, Some(false)); assert_eq!(registered.has_registration, Some(true)); assert_eq!(registered.has_private_registration, Some(true)); assert_eq!( registered.registration_date, - Some(WpDateString("2024-03-15".to_string())) + Some(WpDateString::new("2024-03-15".to_string())) ); assert_eq!(registered.is_renewable, Some(true)); assert_eq!(registered.is_redeemable, Some(false)); @@ -1720,11 +1722,11 @@ mod tests { assert_eq!(registered.contact_info_disclosure_available, Some(true)); assert_eq!( registered.auto_renewal_date, - Some(WpDateString("2027-03-15".to_string())) + Some(WpDateString::new("2027-03-15".to_string())) ); assert_eq!( registered.renewable_until, - Some(WpDateString("2027-04-15".to_string())) + Some(WpDateString::new("2027-04-15".to_string())) ); } @@ -1746,7 +1748,7 @@ mod tests { assert_eq!(google.is_eligible_for_introductory_offer, Some(false)); assert_eq!( google.subscribed_date, - Some(WpDateString("2024-07-15T10:00:00+00:00".to_string())) + Some(WpDateString::new("2024-07-15T10:00:00+00:00".to_string())) ); assert_eq!(google.subscription_id, Some(SubscriptionId(55001))); assert_eq!(google.owned_by_user_id, Some(WpComUserId(33001))); diff --git a/wp_api/src/wp_com/endpoint/stats_utm_endpoint.rs b/wp_api/src/wp_com/endpoint/stats_utm_endpoint.rs index 2035435d1..690d3dee9 100644 --- a/wp_api/src/wp_com/endpoint/stats_utm_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/stats_utm_endpoint.rs @@ -23,6 +23,7 @@ impl DerivedRequest for StatsUtmRequest { mod tests { use super::*; use crate::{ + date::WpDateString, request::endpoint::ApiUrlResolver, wp_com::{ endpoint::tests::{ @@ -73,9 +74,9 @@ mod tests { let utm_keys = StatsUtmKeys(vec![StatsUtmKey::UtmSource]); let params = StatsUtmParams { max: Some(10), - date: Some("2026-03-24".to_string()), + date: Some(WpDateString::new("2026-03-24".to_string())), days: Some(30), - start_date: Some("2026-02-22".to_string()), + start_date: Some(WpDateString::new("2026-02-22".to_string())), query_top_posts: false, }; validate_wp_com_rest_v1_1_endpoint( diff --git a/wp_api/src/wp_com/stats_city_views.rs b/wp_api/src/wp_com/stats_city_views.rs index 401002d58..b4b1c8033 100644 --- a/wp_api/src/wp_com/stats_city_views.rs +++ b/wp_api/src/wp_com/stats_city_views.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -43,10 +44,10 @@ pub struct StatsCityViewsParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of cities to return. #[uniffi(default = None)] pub max: Option, @@ -107,7 +108,7 @@ impl AppendUrlQueryPairs for StatsCityViewsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsCityViewsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// Country information keyed by country code. /// Can be `null`, an empty array `[]`, or a map of country codes to info. #[serde( @@ -192,8 +193,8 @@ mod tests { let params = StatsCityViewsParams { period: Some(StatsCityViewsPeriod::Day), - date: Some("2026-02-05".to_string()), - start_date: Some("2026-01-30".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), + start_date: Some(WpDateString::new("2026-01-30".to_string())), max: Some(10), num: Some(1), days: Some(1), @@ -219,8 +220,8 @@ mod tests { let params = StatsCityViewsParams { period: Some(StatsCityViewsPeriod::Day), - date: Some("2026-02-05".to_string()), - start_date: Some("2026-01-30".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), + start_date: Some(WpDateString::new("2026-01-30".to_string())), locale: Some(WPComLanguage::English), ..Default::default() }; @@ -274,7 +275,7 @@ mod tests { let response: StatsCityViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { assert!( @@ -307,7 +308,7 @@ mod tests { let response: StatsCityViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert!(response.summary.is_some()); assert!(response.days.is_none()); @@ -352,7 +353,7 @@ mod tests { let response: StatsCityViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert!(response.summary.is_none()); assert!(response.days.is_some()); @@ -395,7 +396,7 @@ mod tests { let response: StatsCityViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); let summary = response .summary @@ -459,7 +460,7 @@ mod tests { let response: StatsCityViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with empty response"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); let summary = response .summary diff --git a/wp_api/src/wp_com/stats_clicks.rs b/wp_api/src/wp_com/stats_clicks.rs index c06ddc75e..08e88b574 100644 --- a/wp_api/src/wp_com/stats_clicks.rs +++ b/wp_api/src/wp_com/stats_clicks.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsClicksParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of clicks to return. #[uniffi(default = None)] pub max: Option, @@ -106,7 +107,7 @@ impl AppendUrlQueryPairs for StatsClicksParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsClicksResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping (present when summarize=1). pub period: Option, /// Summary data with aggregated click groups (present when summarize=1). @@ -176,8 +177,8 @@ mod tests { let params = StatsClicksParams { period: Some(StatsClicksPeriod::Day), - date: Some("2026-02-18".to_string()), - start_date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), + start_date: Some(WpDateString::new("2026-02-18".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -202,7 +203,7 @@ mod tests { let params = StatsClicksParams { period: Some(StatsClicksPeriod::Week), - date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), start_date: None, max: None, num: None, @@ -260,7 +261,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { // summarize=1 response: has period and summary, no days @@ -300,7 +301,7 @@ mod tests { let response: StatsClicksResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -332,7 +333,7 @@ mod tests { let response: StatsClicksResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert!(response.summary.is_none()); let days = response.days.as_ref().expect("Days should be present"); @@ -362,7 +363,7 @@ mod tests { let response: StatsClicksResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_country_views.rs b/wp_api/src/wp_com/stats_country_views.rs index d36175c5e..21e45a3a3 100644 --- a/wp_api/src/wp_com/stats_country_views.rs +++ b/wp_api/src/wp_com/stats_country_views.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -43,10 +44,10 @@ pub struct StatsCountryViewsParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of countries to return. #[uniffi(default = None)] pub max: Option, @@ -107,7 +108,7 @@ impl AppendUrlQueryPairs for StatsCountryViewsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsCountryViewsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// Country information keyed by country code. /// Can be `null`, an empty array `[]`, or a map of country codes to info. #[serde( @@ -181,8 +182,8 @@ mod tests { let params = StatsCountryViewsParams { period: Some(StatsCountryViewsPeriod::Day), - date: Some("2026-01-29".to_string()), - start_date: Some("2026-01-29".to_string()), + date: Some(WpDateString::new("2026-01-29".to_string())), + start_date: Some(WpDateString::new("2026-01-29".to_string())), max: Some(10), num: Some(1), days: Some(1), @@ -208,8 +209,8 @@ mod tests { let params = StatsCountryViewsParams { period: Some(StatsCountryViewsPeriod::Day), - date: Some("2026-01-29".to_string()), - start_date: Some("2026-01-23".to_string()), + date: Some(WpDateString::new("2026-01-29".to_string())), + start_date: Some(WpDateString::new("2026-01-23".to_string())), locale: Some(WPComLanguage::English), ..Default::default() }; @@ -274,7 +275,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { // summarize=1 response: has summary, no days @@ -309,7 +310,7 @@ mod tests { let response: StatsCountryViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-29"); + assert_eq!(response.date.value, "2026-01-29"); assert!(response.summary.is_some()); assert!(response.days.is_none()); @@ -348,7 +349,7 @@ mod tests { let response: StatsCountryViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-29"); + assert_eq!(response.date.value, "2026-01-29"); assert!(response.summary.is_none()); assert!(response.days.is_some()); @@ -383,7 +384,7 @@ mod tests { let response: StatsCountryViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-01-29"); + assert_eq!(response.date.value, "2026-01-29"); let summary = response .summary @@ -440,7 +441,7 @@ mod tests { let response: StatsCountryViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with empty response"); - assert_eq!(response.date, "2026-01-29"); + assert_eq!(response.date.value, "2026-01-29"); let summary = response .summary diff --git a/wp_api/src/wp_com/stats_devices.rs b/wp_api/src/wp_com/stats_devices.rs index bcfa4ebfe..43bb40d8f 100644 --- a/wp_api/src/wp_com/stats_devices.rs +++ b/wp_api/src/wp_com/stats_devices.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, }; @@ -42,10 +43,10 @@ pub struct StatsDevicesParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of entries to return. #[uniffi(default = None)] pub max: Option, @@ -116,8 +117,8 @@ mod tests { let params = StatsDevicesParams { period: Some(StatsDevicesPeriod::Day), - date: Some("2026-02-20".to_string()), - start_date: Some("2026-02-14".to_string()), + date: Some(WpDateString::new("2026-02-20".to_string())), + start_date: Some(WpDateString::new("2026-02-14".to_string())), max: Some(10), num: Some(1), days: Some(1), @@ -142,7 +143,7 @@ mod tests { let params = StatsDevicesParams { period: Some(StatsDevicesPeriod::Day), - date: Some("2026-02-20".to_string()), + date: Some(WpDateString::new("2026-02-20".to_string())), ..Default::default() }; diff --git a/wp_api/src/wp_com/stats_emails_summary.rs b/wp_api/src/wp_com/stats_emails_summary.rs index 6739d134c..1f8dda590 100644 --- a/wp_api/src/wp_com/stats_emails_summary.rs +++ b/wp_api/src/wp_com/stats_emails_summary.rs @@ -1,5 +1,7 @@ use crate::{ - WpApiParamOrder, impl_as_query_value_from_to_string, + WpApiParamOrder, + date::WpDateString, + impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, }; use serde::{Deserialize, Serialize}; @@ -106,8 +108,8 @@ pub struct StatsEmailsSummaryPost { pub id: u64, /// The URL of the post. pub href: Option, - /// The publication date of the post. - pub date: Option, + /// The publication date of the post, in the site's timezone. + pub date: Option, /// The title of the post. pub title: Option, /// The type of the content (post, page, etc.). @@ -223,7 +225,10 @@ mod tests { first_post.href, Some("https://example.com/post-1".to_string()) ); - assert_eq!(first_post.date, Some("2023-08-17 15:40:59".to_string())); + assert_eq!( + first_post.date, + Some(WpDateString::new("2023-08-17 15:40:59".to_string())) + ); assert_eq!(first_post.title, Some("Example Post Title".to_string())); assert_eq!(first_post.post_type, Some("post".to_string())); assert_eq!(first_post.opens, Some(13)); diff --git a/wp_api/src/wp_com/stats_file_downloads.rs b/wp_api/src/wp_com/stats_file_downloads.rs index 7e60f1b7b..9cf4a7d84 100644 --- a/wp_api/src/wp_com/stats_file_downloads.rs +++ b/wp_api/src/wp_com/stats_file_downloads.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsFileDownloadsParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of file downloads to return. #[uniffi(default = None)] pub max: Option, @@ -106,7 +107,7 @@ impl AppendUrlQueryPairs for StatsFileDownloadsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsFileDownloadsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping. pub period: Option, /// Summary data with aggregated file download entries (present when summarize=1). @@ -164,8 +165,8 @@ mod tests { let params = StatsFileDownloadsParams { period: Some(StatsFileDownloadsPeriod::Day), - date: Some("2026-02-18".to_string()), - start_date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), + start_date: Some(WpDateString::new("2026-02-18".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -191,7 +192,7 @@ mod tests { let params = StatsFileDownloadsParams { period: Some(StatsFileDownloadsPeriod::Week), - date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), start_date: None, max: None, num: None, @@ -256,7 +257,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { // summarize=1 response: has period and summary @@ -292,7 +293,7 @@ mod tests { let response: StatsFileDownloadsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -320,7 +321,7 @@ mod tests { let response: StatsFileDownloadsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert!(response.summary.is_none()); let days = response.days.as_ref().expect("Days should be present"); @@ -349,7 +350,7 @@ mod tests { let response: StatsFileDownloadsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_post.rs b/wp_api/src/wp_com/stats_post.rs index 42e62f8ec..172d75fc4 100644 --- a/wp_api/src/wp_com/stats_post.rs +++ b/wp_api/src/wp_com/stats_post.rs @@ -1,5 +1,5 @@ use crate::{ - date::WpGmtDateTime, + date::{WpDateString, WpGmtDateTime}, posts::PostId, wp_com::{me::WpComUserId, stats_visits::StatsVisitsDataValue}, }; @@ -74,8 +74,9 @@ impl From for StatsPostTarget { #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] #[serde(from = "RawStatsPostResponse", into = "RawStatsPostResponse")] pub struct StatsPostResponse { - /// The date the stats were generated for (format: YYYY-MM-DD). - pub date: String, + /// The date the stats were generated for, as `YYYY-MM-DD` in the site's + /// timezone. + pub date: WpDateString, /// The target's all-time view count. pub views: u64, /// Yearly view totals, keyed by year (e.g. `"2026"`). @@ -99,6 +100,12 @@ pub struct StatsPostResponse { /// /// Empty if the response doesn't name both the `period` and `views` /// columns. + /// + /// Also empty when the endpoint has no history to pad — a homepage, or a + /// target whose publish date it can't read. It answers those with a single + /// placeholder row whose period is a year-less `M-DD` and whose count is + /// the string `"0"` rather than a number, and a row whose count isn't a + /// number is dropped. pub daily_views: Vec, /// The highest view count the target reached in a single month. pub highest_month: u64, @@ -119,7 +126,7 @@ pub struct StatsPostResponse { /// flattened into [`StatsPostResponse::daily_views`]. #[derive(Serialize, Deserialize)] struct RawStatsPostResponse { - date: String, + date: WpDateString, views: u64, #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] years: HashMap, @@ -174,7 +181,7 @@ impl From for RawStatsPostResponse { .into_iter() .map(|daily_view| { vec![ - StatsVisitsDataValue::String(daily_view.period), + StatsVisitsDataValue::String(daily_view.period.value), StatsVisitsDataValue::Number(daily_view.views), ] }) @@ -216,16 +223,51 @@ fn daily_views(fields: &[String], data: Vec>) -> Vec) -> Self { + Self { + value: value.into(), + } + } +} + /// A single day's view count from the daily view history. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostDailyView { - /// The day the views were recorded on (format: YYYY-MM-DD). - pub period: String, + /// The span the views were bucketed into — a day, in practice, though the + /// endpoint substitutes a year-less `M-DD` when it has no history to + /// report. A label rather than a date, so don't parse it. + pub period: StatsPostViewPeriod, /// The number of views on that day. pub views: u64, } @@ -317,8 +359,8 @@ impl From for RawStatsPostChange { /// A single day within a [`StatsPostWeek`]. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostDay { - /// The day (format: YYYY-MM-DD). - pub day: String, + /// The day the views were bucketed into. + pub day: WpDateString, /// The number of views on that day. pub count: u64, } @@ -353,15 +395,15 @@ pub struct StatsPostDetails { /// The post's excerpt. Empty when the post has none. #[serde(rename = "post_excerpt")] pub excerpt: String, - /// The post's publication date in the site's timezone (format: YYYY-MM-DD HH:MM:SS). + /// The post's publication date in the site's timezone. #[serde(rename = "post_date")] - pub date: String, + pub date: WpDateString, /// The post's publication date in GMT. #[serde(rename = "post_date_gmt")] pub date_gmt: WpGmtDateTime, - /// The date the post was last modified, in the site's timezone (format: YYYY-MM-DD HH:MM:SS). + /// The date the post was last modified, in the site's timezone. #[serde(rename = "post_modified")] - pub modified: String, + pub modified: WpDateString, /// The date the post was last modified, in GMT. #[serde(rename = "post_modified_gmt")] pub modified_gmt: WpGmtDateTime, @@ -423,7 +465,7 @@ mod tests { fn test_stats_post_response_details() { let response = parse(WITH_VIEWS); - assert_eq!(response.date, "2026-08-06"); + assert_eq!(response.date, WpDateString::new("2026-08-06".to_string())); assert_eq!(response.views, 19096); assert_eq!(response.highest_month, 3224); assert_eq!(response.highest_day_average, 293); @@ -450,9 +492,15 @@ mod tests { "The Last Version of FeedDemon is Here, and it's Free" ); assert_eq!(post.excerpt, "The wait is over."); - assert_eq!(post.date, "2013-06-20 09:15:49"); + assert_eq!( + post.date, + WpDateString::new("2013-06-20 09:15:49".to_string()) + ); assert_eq!(post.date_gmt.0.to_rfc3339(), "2013-06-20T13:15:49+00:00"); - assert_eq!(post.modified, "2013-06-23 21:57:23"); + assert_eq!( + post.modified, + WpDateString::new("2013-06-23 21:57:23".to_string()) + ); assert_eq!( post.modified_gmt.0.to_rfc3339(), "2013-06-24T01:57:23+00:00" @@ -482,7 +530,10 @@ mod tests { let first = &weeks[0]; assert_eq!(first.days.len(), 7); - assert_eq!(first.days[0].day, "2026-06-29"); + assert_eq!( + first.days[0].day, + WpDateString::new("2026-06-29".to_string()) + ); assert_eq!(first.days[0].count, 2); assert_eq!(first.total, 7); assert_eq!(first.average, 1); @@ -542,14 +593,14 @@ mod tests { assert_eq!( daily_views[0], StatsPostDailyView { - period: "2013-06-20".to_string(), + period: StatsPostViewPeriod::new("2013-06-20".to_string()), views: 1194, } ); assert_eq!( daily_views[4], StatsPostDailyView { - period: "2026-08-06".to_string(), + period: StatsPostViewPeriod::new("2026-08-06".to_string()), views: 0, } ); @@ -570,6 +621,12 @@ mod tests { vec![("2026-08-04", 5), ("2026-08-06", 7)] )] #[case::missing_views_column(&["period"], r#"[["2026-08-04"]]"#, vec![])] + // The endpoint answers a target it has no history to pad — a homepage, or + // one whose publish date it can't read — with a single placeholder row. + // Its period is a year-less `M-DD`, which is not a date, and its count is + // the string `"0"`, which is what drops the row. If that count ever + // arrives as a number the period reaches `WpDateString`, and this fails. + #[case::no_history_placeholder(&["period", "views"], r#"[["8-06", "0"]]"#, vec![])] fn test_stats_post_daily_views_column_handling( #[case] fields: &[&str], #[case] data: &str, @@ -578,7 +635,7 @@ mod tests { let expected: Vec = expected .into_iter() .map(|(period, views)| StatsPostDailyView { - period: period.to_string(), + period: StatsPostViewPeriod::new(period.to_string()), views, }) .collect(); @@ -600,7 +657,10 @@ mod tests { assert_eq!(response.views, 74286); assert_eq!(response.highest_month, 3822); assert_eq!(response.daily_views.len(), 3); - assert_eq!(response.daily_views[0].period, "2013-05-19"); + assert_eq!( + response.daily_views[0].period, + StatsPostViewPeriod::new("2013-05-19".to_string()) + ); assert_eq!(response.daily_views[0].views, 73); assert_eq!( response.years.get("2013").expect("2013 should exist").total, diff --git a/wp_api/src/wp_com/stats_referrers.rs b/wp_api/src/wp_com/stats_referrers.rs index 3620afef4..180e9dc81 100644 --- a/wp_api/src/wp_com/stats_referrers.rs +++ b/wp_api/src/wp_com/stats_referrers.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsReferrersParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of referrers to return. #[uniffi(default = None)] pub max: Option, @@ -110,7 +111,7 @@ impl AppendUrlQueryPairs for StatsReferrersParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsReferrersResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping (present when summarize=1). pub period: Option, /// Summary data with aggregated referrer groups (present when summarize=1). @@ -269,8 +270,8 @@ mod tests { let params = StatsReferrersParams { period: Some(StatsReferrersPeriod::Day), - date: Some("2026-01-26".to_string()), - start_date: Some("2026-01-26".to_string()), + date: Some(WpDateString::new("2026-01-26".to_string())), + start_date: Some(WpDateString::new("2026-01-26".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -296,7 +297,7 @@ mod tests { let params = StatsReferrersParams { period: Some(StatsReferrersPeriod::Week), - date: Some("2026-01-19".to_string()), + date: Some(WpDateString::new("2026-01-19".to_string())), start_date: None, max: None, num: None, @@ -323,7 +324,7 @@ mod tests { let params = StatsReferrersParams { period: Some(StatsReferrersPeriod::Day), - date: Some("2026-01-26".to_string()), + date: Some(WpDateString::new("2026-01-26".to_string())), summarize: false, skip_archives: None, ..Default::default() @@ -383,7 +384,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { // summarize=1 response: has period and summary, no days @@ -432,7 +433,7 @@ mod tests { let response: StatsReferrersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-26"); + assert_eq!(response.date.value, "2026-01-26"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -534,7 +535,7 @@ mod tests { let response: StatsReferrersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-26"); + assert_eq!(response.date.value, "2026-01-26"); assert!(response.period.is_none()); assert!(response.summary.is_none()); @@ -600,7 +601,7 @@ mod tests { let response: StatsReferrersResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-01-28"); + assert_eq!(response.date.value, "2026-01-28"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_region_views.rs b/wp_api/src/wp_com/stats_region_views.rs index 594d5b529..11cee86d8 100644 --- a/wp_api/src/wp_com/stats_region_views.rs +++ b/wp_api/src/wp_com/stats_region_views.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -43,10 +44,10 @@ pub struct StatsRegionViewsParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of regions to return. #[uniffi(default = None)] pub max: Option, @@ -107,7 +108,7 @@ impl AppendUrlQueryPairs for StatsRegionViewsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsRegionViewsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// Country information keyed by country code. /// Can be `null`, an empty array `[]`, or a map of country codes to info. #[serde( @@ -181,8 +182,8 @@ mod tests { let params = StatsRegionViewsParams { period: Some(StatsRegionViewsPeriod::Day), - date: Some("2026-02-05".to_string()), - start_date: Some("2026-01-30".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), + start_date: Some(WpDateString::new("2026-01-30".to_string())), max: Some(10), num: Some(1), days: Some(1), @@ -208,8 +209,8 @@ mod tests { let params = StatsRegionViewsParams { period: Some(StatsRegionViewsPeriod::Day), - date: Some("2026-02-05".to_string()), - start_date: Some("2026-01-30".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), + start_date: Some(WpDateString::new("2026-01-30".to_string())), locale: Some(WPComLanguage::English), ..Default::default() }; @@ -269,7 +270,7 @@ mod tests { let response: StatsRegionViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { assert!( @@ -302,7 +303,7 @@ mod tests { let response: StatsRegionViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert!(response.summary.is_some()); assert!(response.days.is_none()); @@ -339,7 +340,7 @@ mod tests { let response: StatsRegionViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert!(response.summary.is_none()); assert!(response.days.is_some()); @@ -374,7 +375,7 @@ mod tests { let response: StatsRegionViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); let summary = response .summary @@ -430,7 +431,7 @@ mod tests { let response: StatsRegionViewsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with empty response"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); let summary = response .summary diff --git a/wp_api/src/wp_com/stats_search_terms.rs b/wp_api/src/wp_com/stats_search_terms.rs index ad382ee8b..c8258c8fc 100644 --- a/wp_api/src/wp_com/stats_search_terms.rs +++ b/wp_api/src/wp_com/stats_search_terms.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsSearchTermsParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of search terms to return. #[uniffi(default = None)] pub max: Option, @@ -106,7 +107,7 @@ impl AppendUrlQueryPairs for StatsSearchTermsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsSearchTermsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping (present when summarize=1). pub period: Option, /// Summary data with aggregated search terms (present when summarize=1). @@ -164,8 +165,8 @@ mod tests { let params = StatsSearchTermsParams { period: Some(StatsSearchTermsPeriod::Day), - date: Some("2026-02-18".to_string()), - start_date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), + start_date: Some(WpDateString::new("2026-02-18".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -191,7 +192,7 @@ mod tests { let params = StatsSearchTermsParams { period: Some(StatsSearchTermsPeriod::Week), - date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), start_date: None, max: None, num: None, @@ -256,7 +257,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); if expect_summary { assert!( @@ -294,7 +295,7 @@ mod tests { let response: StatsSearchTermsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -322,7 +323,7 @@ mod tests { let response: StatsSearchTermsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert!(response.summary.is_none()); let days = response.days.as_ref().expect("Days should be present"); @@ -350,7 +351,7 @@ mod tests { let response: StatsSearchTermsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_subscribers.rs b/wp_api/src/wp_com/stats_subscribers.rs index 99097bcbe..2105738e2 100644 --- a/wp_api/src/wp_com/stats_subscribers.rs +++ b/wp_api/src/wp_com/stats_subscribers.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::stats_visits::StatsVisitsDataValue, @@ -72,7 +73,7 @@ pub struct StatsSubscribersParams { pub quantity: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The stat fields to include in the response (comma-separated in the URL). #[uniffi(default = [])] pub stat_fields: Vec, @@ -101,7 +102,7 @@ impl AppendUrlQueryPairs for StatsSubscribersParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsSubscribersResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time unit used for grouping. pub unit: String, /// Field names for the data arrays. @@ -137,7 +138,7 @@ impl StatsSubscribersResponse { fn get_stats_subscribers_data( handle: &str, response: &StatsSubscribersResponse, -) -> Vec<(String, u64)> { +) -> Vec<(StatsSubscribersPeriod, u64)> { let period_index = match response.fields.iter().position(|f| f == "period") { Some(i) => i, None => return vec![], @@ -155,7 +156,7 @@ fn get_stats_subscribers_data( if let Some(period) = row.get(period_index).and_then(|v| v.as_string()) && let Some(value) = row.get(field_index).and_then(|v| v.as_number()) { - return Some((period.clone(), value)); + return Some((StatsSubscribersPeriod::new(period.clone()), value)); } None @@ -163,17 +164,47 @@ fn get_stats_subscribers_data( .collect() } +/// The span a `/stats/subscribers` data point covers, labelled to match the +/// unit the caller asked for: `"2026-01-27"` for a day, `"2026W02W23"` for a +/// week, the first of the month for a month, and the bare year (`"2026"`) for +/// a year. +/// +/// Don't read a date out of it. Its shape follows the unit, and a weekly label +/// such as `2026W02W23` is not a date in any format. Display it, or group by +/// it. +/// +/// Every stats endpoint builds its own labels and none of them agree, which is +/// why each has its own type rather than a shared one; see +/// [`crate::wp_com::stats_visits::StatsVisitsPeriod`] and +/// [`crate::wp_com::stats_post::StatsPostViewPeriod`]. +/// +/// It names a span rather than a point in time, so it is neither a +/// [`crate::date::WpDateString`] nor a [`crate::date::WpGmtDateTime`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct StatsSubscribersPeriod { + pub value: String, +} + +impl StatsSubscribersPeriod { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} + /// A subscriber count data point. #[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, uniffi::Record)] pub struct StatsSubscribersDataPoint { - pub period: String, + pub period: StatsSubscribersPeriod, pub subscribers: u64, } /// A paid subscriber count data point. #[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, uniffi::Record)] pub struct StatsSubscribersPaidDataPoint { - pub period: String, + pub period: StatsSubscribersPeriod, pub subscribers_paid: u64, } @@ -192,7 +223,7 @@ mod tests { let params = StatsSubscribersParams { unit: Some(StatsSubscribersUnit::Day), quantity: Some(30), - date: Some("2026-02-26".to_string()), + date: Some(WpDateString::new("2026-02-26".to_string())), stat_fields: vec![ StatsSubscribersStatField::Subscribers, StatsSubscribersStatField::SubscribersPaid, @@ -268,7 +299,7 @@ mod tests { let response: StatsSubscribersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-27"); + assert_eq!(response.date.value, "2026-01-27"); assert_eq!(response.unit, "day"); assert_eq!( response.fields, @@ -281,7 +312,7 @@ mod tests { assert_eq!( subscribers[0], StatsSubscribersDataPoint { - period: "2026-01-27".to_string(), + period: StatsSubscribersPeriod::new("2026-01-27".to_string()), subscribers: 89, } ); @@ -291,7 +322,7 @@ mod tests { assert_eq!( paid[0], StatsSubscribersPaidDataPoint { - period: "2026-01-27".to_string(), + period: StatsSubscribersPeriod::new("2026-01-27".to_string()), subscribers_paid: 0, } ); @@ -304,7 +335,7 @@ mod tests { let response: StatsSubscribersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-26"); + assert_eq!(response.date.value, "2026-02-26"); assert_eq!(response.unit, "week"); assert_eq!(response.data.len(), 12); @@ -313,7 +344,7 @@ mod tests { assert_eq!( subscribers[0], StatsSubscribersDataPoint { - period: "2026W02W23".to_string(), + period: StatsSubscribersPeriod::new("2026W02W23".to_string()), subscribers: 89, } ); @@ -326,7 +357,7 @@ mod tests { let response: StatsSubscribersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-26"); + assert_eq!(response.date.value, "2026-02-26"); assert_eq!(response.unit, "month"); assert_eq!(response.data.len(), 6); @@ -335,7 +366,7 @@ mod tests { assert_eq!( subscribers[3], StatsSubscribersDataPoint { - period: "2025-11-01".to_string(), + period: StatsSubscribersPeriod::new("2025-11-01".to_string()), subscribers: 90, } ); @@ -348,7 +379,7 @@ mod tests { let response: StatsSubscribersResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-26"); + assert_eq!(response.date.value, "2026-02-26"); assert_eq!(response.unit, "year"); assert_eq!(response.data.len(), 3); @@ -357,7 +388,7 @@ mod tests { assert_eq!( subscribers[2], StatsSubscribersDataPoint { - period: "2024".to_string(), + period: StatsSubscribersPeriod::new("2024".to_string()), subscribers: 114, } ); diff --git a/wp_api/src/wp_com/stats_summary.rs b/wp_api/src/wp_com/stats_summary.rs index f517ee2af..1cc713c33 100644 --- a/wp_api/src/wp_com/stats_summary.rs +++ b/wp_api/src/wp_com/stats_summary.rs @@ -1,4 +1,5 @@ use crate::{ + date::{WpDateString, WpGmtDateTime, deserialize_optional_wp_gmt_date_time}, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::{language::WPComLanguage, stats_visits::StatsVisitsResponse}, }; @@ -24,7 +25,7 @@ impl AppendUrlQueryPairs for StatsSummaryParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsSummaryResponse { /// The date of the stats query. - pub date: String, + pub date: WpDateString, /// Aggregate site statistics. pub stats: StatsSummaryStats, /// Recent visit time-series data. @@ -44,8 +45,8 @@ pub struct StatsSummaryStats { pub views_today: u64, /// Number of views yesterday. pub views_yesterday: u64, - /// The date with the most views (format: YYYY-MM-DD). - pub views_best_day: String, + /// The date with the most views. + pub views_best_day: WpDateString, /// The total views on the best day. pub views_best_day_total: u64, /// Total number of views. @@ -60,10 +61,13 @@ pub struct StatsSummaryStats { pub followers_comments: u64, /// Average comments per month. pub comments_per_month: u64, - /// The most active recent day for comments. - /// Empty strings from the API are treated as `None`. - #[serde(deserialize_with = "wp_serde_helper::deserialize_empty_string_as_none")] - pub comments_most_active_recent_day: Option, + /// The most active recent day for comments. The API derives this from a + /// comment's GMT timestamp, so it carries a time of day that isn't part of + /// what the field reports. + /// + /// An empty string, `null`, or an absent field all read as `None`. + #[serde(default, deserialize_with = "deserialize_optional_wp_gmt_date_time")] + pub comments_most_active_recent_day: Option, /// The most active time for comments. /// `"N/A"` values from the API are treated as `None`. #[serde(deserialize_with = "wp_serde_helper::deserialize_placeholder_string_as_none")] @@ -125,7 +129,7 @@ mod tests { let response: StatsSummaryResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-03-10"); + assert_eq!(response.date.value, "2026-03-10"); // Verify stats assert_eq!(response.stats.visitors_today, 222); @@ -133,7 +137,7 @@ mod tests { assert_eq!(response.stats.visitors, 154791); assert_eq!(response.stats.views_today, 745); assert_eq!(response.stats.views_yesterday, 1405); - assert_eq!(response.stats.views_best_day, "2022-02-22"); + assert_eq!(response.stats.views_best_day.value, "2022-02-22"); assert_eq!(response.stats.views_best_day_total, 4615); assert_eq!(response.stats.views, 6782783); assert_eq!(response.stats.comments, 0); @@ -162,12 +166,12 @@ mod tests { let visits = response.visits.visits_data(); assert_eq!(visits.len(), 30); - assert_eq!(visits[0].period, "2026-02-09"); + assert_eq!(visits[0].period.value, "2026-02-09"); assert_eq!(visits[0].visits, 1384); let visitors = response.visits.visitors_data(); assert_eq!(visitors.len(), 30); - assert_eq!(visitors[0].period, "2026-02-09"); + assert_eq!(visitors[0].period.value, "2026-02-09"); assert_eq!(visitors[0].visitors, 376); } @@ -178,7 +182,7 @@ mod tests { let response: StatsSummaryResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-03-10"); + assert_eq!(response.date.value, "2026-03-10"); // Verify all stats are zero assert_eq!(response.stats.visitors_today, 0); @@ -186,7 +190,7 @@ mod tests { assert_eq!(response.stats.visitors, 0); assert_eq!(response.stats.views_today, 0); assert_eq!(response.stats.views_yesterday, 0); - assert_eq!(response.stats.views_best_day, ""); + assert_eq!(response.stats.views_best_day.value, ""); assert_eq!(response.stats.views_best_day_total, 0); assert_eq!(response.stats.views, 0); assert_eq!(response.stats.comments, 0); diff --git a/wp_api/src/wp_com/stats_tags.rs b/wp_api/src/wp_com/stats_tags.rs index 600f09c76..cc26cf091 100644 --- a/wp_api/src/wp_com/stats_tags.rs +++ b/wp_api/src/wp_com/stats_tags.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, }; @@ -27,7 +28,7 @@ impl AppendUrlQueryPairs for StatsTagsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsTagsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The list of tag groups with their view counts. pub tags: Vec, } @@ -101,7 +102,7 @@ mod tests { let response: StatsTagsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-03-12"); + assert_eq!(response.date.value, "2026-03-12"); assert_eq!(response.tags.len(), 3); let first = &response.tags[0]; @@ -123,7 +124,7 @@ mod tests { let response: StatsTagsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-03-12"); + assert_eq!(response.date.value, "2026-03-12"); assert!(response.tags.is_empty()); } } diff --git a/wp_api/src/wp_com/stats_top_authors.rs b/wp_api/src/wp_com/stats_top_authors.rs index 9ee8826c4..ed51e584c 100644 --- a/wp_api/src/wp_com/stats_top_authors.rs +++ b/wp_api/src/wp_com/stats_top_authors.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsTopAuthorsParams { pub period: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The maximum number of top authors to return. #[uniffi(default = None)] pub max: Option, @@ -98,7 +99,7 @@ impl AppendUrlQueryPairs for StatsTopAuthorsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsTopAuthorsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping (present when summarize=1). pub period: Option, /// Summary data with aggregated author views (present when summarize=1). @@ -210,8 +211,8 @@ mod tests { let params = StatsTopAuthorsParams { period: Some(StatsTopAuthorsPeriod::Day), - start_date: Some("2026-01-30".to_string()), - date: Some("2026-02-05".to_string()), + start_date: Some(WpDateString::new("2026-01-30".to_string())), + date: Some(WpDateString::new("2026-02-05".to_string())), max: Some(10), num: Some(7), locale: Some(WPComLanguage::English), @@ -237,7 +238,7 @@ mod tests { let params = StatsTopAuthorsParams { period: Some(StatsTopAuthorsPeriod::Week), start_date: None, - date: Some("2026-02-05".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), max: None, num: None, locale: None, @@ -262,7 +263,7 @@ mod tests { let params = StatsTopAuthorsParams { period: Some(StatsTopAuthorsPeriod::Day), - date: Some("2026-02-05".to_string()), + date: Some(WpDateString::new("2026-02-05".to_string())), summarize: false, ..Default::default() }; @@ -300,7 +301,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(response.period.is_some()); assert!(!response.period.as_ref().unwrap().is_empty()); @@ -317,7 +318,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -346,7 +347,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(response.summary.is_none()); assert!(response.days.is_some()); } @@ -358,7 +359,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert!(response.period.is_none()); assert!(response.summary.is_none()); @@ -400,7 +401,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with empty response"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -417,7 +418,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-05"); + assert_eq!(response.date.value, "2026-02-05"); let summary = response .summary @@ -487,7 +488,7 @@ mod tests { let response: StatsTopAuthorsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with mixed follow_data"); - assert_eq!(response.date, "2026-02-02"); + assert_eq!(response.date.value, "2026-02-02"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_top_posts.rs b/wp_api/src/wp_com/stats_top_posts.rs index 331819b72..bd944994a 100644 --- a/wp_api/src/wp_com/stats_top_posts.rs +++ b/wp_api/src/wp_com/stats_top_posts.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -42,10 +43,10 @@ pub struct StatsTopPostsParams { pub period: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The maximum number of top posts to return. #[uniffi(default = None)] pub max: Option, @@ -111,7 +112,7 @@ impl AppendUrlQueryPairs for StatsTopPostsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsTopPostsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping (present when summarize=1). pub period: Option, /// Summary data with aggregated post views (present when summarize=1). @@ -154,7 +155,7 @@ pub struct StatsTopPostsPostView { /// The URL of the post (can be null for homepage). pub href: Option, /// The publication date of the post (can be null for homepage). - pub date: Option, + pub date: Option, /// The title of the post. pub title: Option, /// The type of the content (post, page, homepage, etc.). @@ -184,8 +185,8 @@ mod tests { let params = StatsTopPostsParams { period: Some(StatsTopPostsPeriod::Day), - start_date: Some("2026-01-26".to_string()), - date: Some("2026-01-26".to_string()), + start_date: Some(WpDateString::new("2026-01-26".to_string())), + date: Some(WpDateString::new("2026-01-26".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -212,7 +213,7 @@ mod tests { let params = StatsTopPostsParams { period: Some(StatsTopPostsPeriod::Week), start_date: None, - date: Some("2026-01-19".to_string()), + date: Some(WpDateString::new("2026-01-19".to_string())), max: None, num: None, locale: None, @@ -238,7 +239,7 @@ mod tests { let params = StatsTopPostsParams { period: Some(StatsTopPostsPeriod::Day), - date: Some("2026-01-26".to_string()), + date: Some(WpDateString::new("2026-01-26".to_string())), summarize: false, skip_archives: None, ..Default::default() @@ -285,7 +286,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(response.period.is_some()); assert!(!response.period.as_ref().unwrap().is_empty()); @@ -302,7 +303,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-25"); + assert_eq!(response.date.value, "2026-01-25"); assert_eq!(response.period, Some("week".to_string())); let summary = response @@ -358,7 +359,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(response.summary.is_none()); assert!(response.days.is_some()); } @@ -370,7 +371,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-26"); + assert_eq!(response.date.value, "2026-01-26"); assert!(response.period.is_none()); assert!(response.summary.is_none()); @@ -405,7 +406,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-01-28"); + assert_eq!(response.date.value, "2026-01-28"); assert_eq!(response.period, Some("day".to_string())); let summary = response @@ -434,7 +435,10 @@ mod tests { all_values.href, Some("https://example.com/post".to_string()) ); - assert_eq!(all_values.date, Some("2026-01-28 10:00:00".to_string())); + assert_eq!( + all_values.date, + Some(WpDateString::new("2026-01-28 10:00:00".to_string())) + ); assert_eq!(all_values.title, Some("A Post With All Fields".to_string())); assert_eq!(all_values.post_type, Some("post".to_string())); assert_eq!(all_values.status, Some("publish".to_string())); @@ -462,7 +466,7 @@ mod tests { let response: StatsTopPostsResponse = serde_json::from_reader(file).expect("Unable to parse JSON with missing dropped_ids"); - assert_eq!(response.date, "2026-01-28"); + assert_eq!(response.date.value, "2026-01-28"); assert_eq!(response.period, Some("day".to_string())); let summary = response diff --git a/wp_api/src/wp_com/stats_utm.rs b/wp_api/src/wp_com/stats_utm.rs index ab46fb2be..62b6a4642 100644 --- a/wp_api/src/wp_com/stats_utm.rs +++ b/wp_api/src/wp_com/stats_utm.rs @@ -1,3 +1,4 @@ +use crate::date::WpDateString; use crate::url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -61,13 +62,13 @@ pub struct StatsUtmParams { pub max: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The number of days to include in the query. #[uniffi(default = None)] pub days: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// Whether to include top posts data in the response. #[uniffi(default = true)] pub query_top_posts: bool, @@ -175,9 +176,9 @@ mod tests { let params = StatsUtmParams { max: Some(0), - date: Some("2026-03-24".to_string()), + date: Some(WpDateString::new("2026-03-24".to_string())), days: Some(365), - start_date: Some("2026-03-24".to_string()), + start_date: Some(WpDateString::new("2026-03-24".to_string())), query_top_posts: true, }; @@ -198,7 +199,7 @@ mod tests { .expect("Failed to parse url"); let params = StatsUtmParams { - date: Some("2026-03-24".to_string()), + date: Some(WpDateString::new("2026-03-24".to_string())), days: Some(1), ..Default::default() }; diff --git a/wp_api/src/wp_com/stats_video_plays.rs b/wp_api/src/wp_com/stats_video_plays.rs index 38caeb8f5..3a3370545 100644 --- a/wp_api/src/wp_com/stats_video_plays.rs +++ b/wp_api/src/wp_com/stats_video_plays.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -41,10 +42,10 @@ pub struct StatsVideoPlaysParams { pub period: Option, /// The date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub date: Option, + pub date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The maximum number of video plays to return. #[uniffi(default = None)] pub max: Option, @@ -108,7 +109,7 @@ impl AppendUrlQueryPairs for StatsVideoPlaysParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsVideoPlaysResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time period used for grouping. pub period: Option, /// The days data containing the summary. @@ -173,8 +174,8 @@ mod tests { let params = StatsVideoPlaysParams { period: Some(StatsVideoPlaysPeriod::Day), - date: Some("2026-02-18".to_string()), - start_date: Some("2026-02-12".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), + start_date: Some(WpDateString::new("2026-02-12".to_string())), max: Some(10), num: Some(30), locale: Some(WPComLanguage::English), @@ -200,7 +201,7 @@ mod tests { let params = StatsVideoPlaysParams { period: Some(StatsVideoPlaysPeriod::Week), - date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), start_date: None, max: None, num: None, @@ -227,7 +228,7 @@ mod tests { let params = StatsVideoPlaysParams { period: Some(StatsVideoPlaysPeriod::Day), - date: Some("2026-02-18".to_string()), + date: Some(WpDateString::new("2026-02-18".to_string())), summarize: false, complete_stats: None, ..Default::default() @@ -277,7 +278,7 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON"); // Common assertion: date is always present - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(response.period.is_some()); } @@ -288,7 +289,7 @@ mod tests { let response: StatsVideoPlaysResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); assert_eq!(response.period, Some("day".to_string())); let summary = &response.days.summary; @@ -312,7 +313,7 @@ mod tests { let response: StatsVideoPlaysResponse = serde_json::from_reader(file).expect("Unable to parse JSON with null values"); - assert_eq!(response.date, "2026-02-18"); + assert_eq!(response.date.value, "2026-02-18"); let summary = &response.days.summary; assert_eq!(summary.data.len(), 2); diff --git a/wp_api/src/wp_com/stats_visits.rs b/wp_api/src/wp_com/stats_visits.rs index f333e5d39..9866d30bc 100644 --- a/wp_api/src/wp_com/stats_visits.rs +++ b/wp_api/src/wp_com/stats_visits.rs @@ -1,4 +1,5 @@ use crate::{ + date::WpDateString, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, wp_com::language::WPComLanguage, @@ -73,10 +74,10 @@ pub struct StatsVisitsParams { pub quantity: Option, /// The end date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub end_date: Option, + pub end_date: Option, /// The start date to query stats for (format: YYYY-MM-DD). #[uniffi(default = None)] - pub start_date: Option, + pub start_date: Option, /// The specific stat fields to include in the response. /// When empty, the API returns its default set of fields. #[uniffi(default = [])] @@ -102,7 +103,7 @@ impl AppendUrlQueryPairs for StatsVisitsParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record)] pub struct StatsVisitsResponse { /// The date for the stats query. - pub date: String, + pub date: WpDateString, /// The time unit used for grouping. pub unit: String, /// Field names for the data arrays. @@ -156,7 +157,7 @@ impl StatsVisitsResponse { } } -fn get_stats_data(handle: &str, response: &StatsVisitsResponse) -> Vec<(String, u64)> { +fn get_stats_data(handle: &str, response: &StatsVisitsResponse) -> Vec<(StatsVisitsPeriod, u64)> { let period_index = match response.fields.iter().position(|f| f == "period") { Some(i) => i, None => return vec![], @@ -174,46 +175,75 @@ fn get_stats_data(handle: &str, response: &StatsVisitsResponse) -> Vec<(String, if let Some(period) = row.get(period_index).and_then(|v| v.as_string()) && let Some(value) = row.get(field_index).and_then(|v| v.as_number()) { - return Some((period.clone(), value)); + return Some((StatsVisitsPeriod::new(period.clone()), value)); } None }) .collect() } +/// The span a `/stats/visits` data point covers, labelled to match the unit +/// the caller asked for: `"2025-12-21"` for a day, `"2026-01-17 01:00:00"` for +/// an hour, and for a year the first of January rather than the bare year. +/// +/// Don't read a date out of it. Its shape follows the unit, and a yearly label +/// is the first of January standing in for the whole year — so it parses as a +/// date and means something else. Display it, or group by it. +/// +/// Every stats endpoint builds its own labels and none of them agree, which is +/// why each has its own type rather than a shared one; see +/// [`crate::wp_com::stats_subscribers::StatsSubscribersPeriod`] and +/// [`crate::wp_com::stats_post::StatsPostViewPeriod`]. +/// +/// It names a span rather than a point in time, so it is neither a +/// [`WpDateString`] nor a [`crate::date::WpGmtDateTime`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct StatsVisitsPeriod { + pub value: String, +} + +impl StatsVisitsPeriod { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} + #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsVisitsDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub visits: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsVisitorsDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub visitors: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsLikesDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub likes: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsReblogsDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub reblogs: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsCommentsDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub comments: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostsDataPoint { - pub period: String, + pub period: StatsVisitsPeriod, pub posts: u64, } @@ -260,7 +290,7 @@ mod tests { let params = StatsVisitsParams { unit: Some(StatsVisitsUnit::Hour), quantity: Some(24), - end_date: Some("2025-01-15".to_string()), + end_date: Some(WpDateString::new("2025-01-15".to_string())), start_date: None, stat_fields: vec![], locale: Some(WPComLanguage::English), @@ -308,8 +338,8 @@ mod tests { let params = StatsVisitsParams { unit: Some(StatsVisitsUnit::Month), quantity: Some(12), - end_date: Some("2026-07-13".to_string()), - start_date: Some("2025-08-01".to_string()), + end_date: Some(WpDateString::new("2026-07-13".to_string())), + start_date: Some(WpDateString::new("2025-08-01".to_string())), stat_fields: vec![StatsVisitsField::Views, StatsVisitsField::Visitors], locale: None, }; @@ -355,7 +385,7 @@ mod tests { let response: StatsVisitsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert!(!response.date.is_empty()); + assert!(!response.date.value.is_empty()); assert!(!response.unit.is_empty()); assert!(!response.fields.is_empty()); } @@ -367,7 +397,7 @@ mod tests { let response: StatsVisitsResponse = serde_json::from_reader(file).expect("Unable to parse JSON"); - assert_eq!(response.date, "2026-01-18 00:00:00"); + assert_eq!(response.date.value, "2026-01-18 00:00:00"); assert_eq!(response.unit, "hour"); assert_eq!( response.fields, @@ -401,21 +431,21 @@ mod tests { assert_eq!( data_points[0], StatsVisitsDataPoint { - period: "2026-01-17 01:00:00".to_string(), + period: StatsVisitsPeriod::new("2026-01-17 01:00:00".to_string()), visits: 9, } ); assert_eq!( data_points[21], StatsVisitsDataPoint { - period: "2026-01-17 22:00:00".to_string(), + period: StatsVisitsPeriod::new("2026-01-17 22:00:00".to_string()), visits: 27, } ); assert_eq!( data_points[23], StatsVisitsDataPoint { - period: "2026-01-18 00:00:00".to_string(), + period: StatsVisitsPeriod::new("2026-01-18 00:00:00".to_string()), visits: 4, } ); @@ -434,21 +464,21 @@ mod tests { assert_eq!( data_points[0], StatsVisitsDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), visits: 67, } ); assert_eq!( data_points[20], StatsVisitsDataPoint { - period: "2026-01-10".to_string(), + period: StatsVisitsPeriod::new("2026-01-10".to_string()), visits: 57, } ); assert_eq!( data_points[29], StatsVisitsDataPoint { - period: "2026-01-19".to_string(), + period: StatsVisitsPeriod::new("2026-01-19".to_string()), visits: 50, } ); @@ -457,7 +487,7 @@ mod tests { #[test] fn test_get_stats_visits_data_empty_response() { let response = StatsVisitsResponse { - date: "2026-01-19".to_string(), + date: WpDateString::new("2026-01-19".to_string()), unit: "day".to_string(), fields: vec![ "period".to_string(), @@ -499,21 +529,21 @@ mod tests { assert_eq!( data_points[0], StatsVisitorsDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), visitors: 60, } ); assert_eq!( data_points[20], StatsVisitorsDataPoint { - period: "2026-01-10".to_string(), + period: StatsVisitsPeriod::new("2026-01-10".to_string()), visitors: 50, } ); assert_eq!( data_points[29], StatsVisitorsDataPoint { - period: "2026-01-19".to_string(), + period: StatsVisitsPeriod::new("2026-01-19".to_string()), visitors: 47, } ); @@ -533,14 +563,14 @@ mod tests { assert_eq!( data_points[0], StatsLikesDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), likes: 0, } ); assert_eq!( data_points[15], StatsLikesDataPoint { - period: "2026-01-05".to_string(), + period: StatsVisitsPeriod::new("2026-01-05".to_string()), likes: 1, } ); @@ -559,7 +589,7 @@ mod tests { assert_eq!( data_points[0], StatsReblogsDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), reblogs: 0, } ); @@ -578,7 +608,7 @@ mod tests { assert_eq!( data_points[0], StatsCommentsDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), comments: 0, } ); @@ -597,7 +627,7 @@ mod tests { assert_eq!( data_points[0], StatsPostsDataPoint { - period: "2025-12-21".to_string(), + period: StatsVisitsPeriod::new("2025-12-21".to_string()), posts: 0, } ); diff --git a/wp_api/src/wp_com/subscribers.rs b/wp_api/src/wp_com/subscribers.rs index c2e4f2457..1d86a9394 100644 --- a/wp_api/src/wp_com/subscribers.rs +++ b/wp_api/src/wp_com/subscribers.rs @@ -1,6 +1,6 @@ use crate::{ WpApiParamOrder, - date::WpGmtDateTime, + date::{WpGmtDateTime, deserialize_optional_wp_gmt_date_time}, impl_as_query_value_for_new_type, impl_as_query_value_from_to_string, url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension}, users::UserId, @@ -16,7 +16,10 @@ pub struct Subscriber { pub display_name: String, pub email_address: String, pub is_email_subscriber: bool, - pub date_subscribed: WpGmtDateTime, + /// When the subscription started. `None` when WordPress.com has never set + /// it, which it reports with its zero date rather than `null`. + #[serde(default, deserialize_with = "deserialize_optional_wp_gmt_date_time")] + pub date_subscribed: Option, pub subscription_status: Option, pub avatar: String, pub url: Option, @@ -334,7 +337,15 @@ pub struct IndividualSubscriberStats { emails_sent: u64, unique_opens: u64, unique_clicks: u64, - blog_registration_date: String, + /// When the site was registered, in GMT. `None` when it was never set: + /// this endpoint returns the column verbatim without guarding it, so a + /// site with no registration date arrives as WordPress's zero date + /// rather than `null`. + #[serde( + default, + deserialize_with = "crate::date::deserialize_optional_wp_gmt_date_time" + )] + blog_registration_date: Option, } // MARK: - Add Subscribers @@ -510,6 +521,7 @@ pub struct SubscriberSnapshot { mod tests { use super::*; use crate::api_error::WpError; + use rstest::rstest; #[test] fn test_list_subscribers_parameters_serialization() { @@ -577,12 +589,39 @@ mod tests { assert_eq!(response.subscribers.len(), 4); } + /// The fixture's second subscriber carries WordPress.com's zero date, + /// which reads as absent rather than failing the whole list. #[test] - fn test_subscriber_list_with_invalid_date_returns_parsing_error() { + fn test_subscriber_list_reads_a_never_set_date_as_absent() { let json_file_path = "tests/wpcom/subscribers/subscriber-list-with-invalid-date.json"; let file = std::fs::File::open(json_file_path).expect("Failed to open file"); - let result: Result = serde_json::from_reader(file); - assert!(result.is_err(), "Expected parsing error for malformed date"); + let response: ListSubscribersResponse = + serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert!(response.subscribers[0].date_subscribed.is_some()); + assert_eq!(response.subscribers[1].date_subscribed, None); + } + + /// A `date_subscribed` that is neither a datetime nor the zero date + /// still fails the response — absent and malformed stay distinct. + #[rstest] + #[case::not_a_date("banana")] + #[case::before_year_one("-0500-01-01T00:00:00")] + fn test_subscriber_with_malformed_date_returns_parsing_error(#[case] date_subscribed: &str) { + let json = serde_json::json!({ + "user_id": 1, + "subscription_id": 2, + "display_name": "Test User", + "email_address": "user@example.com", + "is_email_subscriber": true, + "date_subscribed": date_subscribed, + "avatar": "https://example.com/avatar", + }); + + assert!( + serde_json::from_value::(json).is_err(), + "Expected parsing error for {date_subscribed}" + ); } #[test] @@ -804,13 +843,18 @@ mod tests { assert_eq!(first.url, Some("https://nikhilc.dev".to_string())); } + /// As [`test_subscriber_list_reads_a_never_set_date_as_absent`], for the + /// by-user-type response shape. #[test] - fn test_subscribers_by_user_type_with_invalid_date_returns_parsing_error() { + fn test_subscribers_by_user_type_reads_a_never_set_date_as_absent() { let json_file_path = "tests/wpcom/subscribers/subscribers-by-user-type-with-invalid-date.json"; let file = std::fs::File::open(json_file_path).expect("Failed to open file"); - let result: Result = serde_json::from_reader(file); - assert!(result.is_err(), "Expected parsing error for malformed date"); + let response: ListSubscribersResponse = + serde_json::from_reader(file).expect("Unable to parse JSON"); + + assert!(response.subscribers[0].date_subscribed.is_some()); + assert_eq!(response.subscribers[1].date_subscribed, None); } #[test] diff --git a/wp_api/src/wp_com/transactions.rs b/wp_api/src/wp_com/transactions.rs index 7c954984c..a0c467bb6 100644 --- a/wp_api/src/wp_com/transactions.rs +++ b/wp_api/src/wp_com/transactions.rs @@ -226,7 +226,7 @@ mod tests { assert_eq!(domain.blog_id, Some(WpComSiteId(98765432))); assert_eq!( domain.expiry, - Some(WpDateString("2030-01-15".to_string())), + Some(WpDateString::new("2030-01-15".to_string())), "expiry is a date without a time component" ); assert!(domain.product_name_short.is_none()); diff --git a/wp_api_integration_tests/tests/test_blocks_immut.rs b/wp_api_integration_tests/tests/test_blocks_immut.rs index e021a8473..7b0f05737 100644 --- a/wp_api_integration_tests/tests/test_blocks_immut.rs +++ b/wp_api_integration_tests/tests/test_blocks_immut.rs @@ -106,10 +106,10 @@ fn block_id() -> BlockId { #[case::page(generate!(BlockListParams, (page, Some(1))))] #[case::per_page(generate!(BlockListParams, (per_page, Some(3))))] #[case::search(generate!(BlockListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(BlockListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] -#[case::modified_after(generate!(BlockListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] -#[case::before(generate!(BlockListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0200")))))] -#[case::modified_before(generate!(BlockListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] +#[case::after(generate!(BlockListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] +#[case::modified_after(generate!(BlockListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] +#[case::before(generate!(BlockListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+02:00")))))] +#[case::modified_before(generate!(BlockListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] #[case::exclude(generate!(BlockListParams, (exclude, vec![BlockId(1), BlockId(2)])))] #[case::include(generate!(BlockListParams, (include, vec![BlockId(1)])))] #[case::offset(generate!(BlockListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_blocks_mut.rs b/wp_api_integration_tests/tests/test_blocks_mut.rs index 6b208944b..4d637aedf 100644 --- a/wp_api_integration_tests/tests/test_blocks_mut.rs +++ b/wp_api_integration_tests/tests/test_blocks_mut.rs @@ -2,6 +2,7 @@ use macro_helper::{generate_update_block_status_test, generate_update_test}; use wp_api::blocks::{ BlockCreateParams, BlockId, BlockStatus, BlockUpdateParams, BlockWithEditContext, }; +use wp_api::date::WpDateString; use wp_api_integration_tests::prelude::*; #[tokio::test] @@ -79,20 +80,20 @@ async fn trash_block() { generate_update_test!( update_date, date, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + WpDateString::new("2024-09-09T12:00:00".to_string()), |updated_block| { - assert_eq!(updated_block.date, "2024-09-09T12:00:00"); + assert_eq!(updated_block.date.value, "2024-09-09T12:00:00"); } ); generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00"), |updated_block| { assert_eq!( updated_block.date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00") ); } ); @@ -134,7 +135,7 @@ async fn update_status_to_future() { test_update_block( &BlockUpdateParams { status: Some(BlockStatus::Future), - date: Some(unwrapped_wp_gmt_date_time("2026-09-09T12:00:00+0000")), + date: Some(WpDateString::new("2026-09-09T12:00:00".to_string())), ..Default::default() }, |updated_block| { diff --git a/wp_api_integration_tests/tests/test_comments_immut.rs b/wp_api_integration_tests/tests/test_comments_immut.rs index 06b1dacd9..c4131fe46 100644 --- a/wp_api_integration_tests/tests/test_comments_immut.rs +++ b/wp_api_integration_tests/tests/test_comments_immut.rs @@ -231,11 +231,11 @@ async fn parse_extras() { #[case::page(generate!(CommentListParams, (page, Some(1))))] #[case::per_page(generate!(CommentListParams, (per_page, Some(3))))] #[case::search(generate!(CommentListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(CommentListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] +#[case::after(generate!(CommentListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] #[case::author(generate!(CommentListParams, (author, vec![FIRST_USER_ID, SECOND_USER_ID])))] #[case::author_exclude(generate!(CommentListParams, (author_exclude, vec![SECOND_USER_ID])))] #[case::author_email(generate!(CommentListParams, (author_email, Some(FIRST_USER_EMAIL.to_string()))))] -#[case::before(generate!(CommentListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0000")))))] +#[case::before(generate!(CommentListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] #[case::exclude(generate!(CommentListParams, (exclude, vec![CommentId(1), CommentId(2)])))] #[case::include(generate!(CommentListParams, (include, vec![CommentId(1)])))] #[case::offset(generate!(CommentListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_comments_mut.rs b/wp_api_integration_tests/tests/test_comments_mut.rs index 38c42292e..44f230f48 100644 --- a/wp_api_integration_tests/tests/test_comments_mut.rs +++ b/wp_api_integration_tests/tests/test_comments_mut.rs @@ -3,6 +3,7 @@ use wp_api::comments::{ CommentCreateParams, CommentCreateParamsBuilder, CommentDeleteParams, CommentStatus, CommentUpdateParams, CommentWithEditContext, }; +use wp_api::date::WpDateString; use wp_api_integration_tests::prelude::*; use wp_cli::WpCliComment; @@ -160,9 +161,9 @@ generate_update_test!( generate_update_test!( update_date, date, - "2024-09-09T12:00:00".to_string(), + WpDateString::new("2024-09-09T12:00:00".to_string()), |updated_comment, updated_comment_from_wp_cli| { - assert_eq!(updated_comment.date, "2024-09-09T12:00:00"); + assert_eq!(updated_comment.date.value, "2024-09-09T12:00:00"); assert_eq!(updated_comment_from_wp_cli.date, "2024-09-09 12:00:00"); } ); @@ -170,11 +171,11 @@ generate_update_test!( generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00"), |updated_comment, updated_comment_from_wp_cli| { assert_eq!( updated_comment.date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + Some(unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00")) ); assert_eq!(updated_comment_from_wp_cli.date_gmt, "2024-09-09 12:00:00"); } diff --git a/wp_api_integration_tests/tests/test_media_immut.rs b/wp_api_integration_tests/tests/test_media_immut.rs index cbdae4492..8a800c274 100644 --- a/wp_api_integration_tests/tests/test_media_immut.rs +++ b/wp_api_integration_tests/tests/test_media_immut.rs @@ -105,12 +105,12 @@ async fn paginate_list_media_with_edit_context(#[case] params: MediaListParams) #[case::page(generate!(MediaListParams, (page, Some(1))))] #[case::per_page(generate!(MediaListParams, (per_page, Some(3))))] #[case::search(generate!(MediaListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(MediaListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] -#[case::modified_after(generate!(MediaListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] +#[case::after(generate!(MediaListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] +#[case::modified_after(generate!(MediaListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] #[case::author(generate!(MediaListParams, (author, vec![FIRST_USER_ID, SECOND_USER_ID])))] #[case::author_exclude(generate!(MediaListParams, (author_exclude, vec![SECOND_USER_ID])))] -#[case::before(generate!(MediaListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0000")))))] -#[case::modified_before(generate!(MediaListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0000")))))] +#[case::before(generate!(MediaListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] +#[case::modified_before(generate!(MediaListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] #[case::exclude(generate!(MediaListParams, (exclude, vec![MediaId(1), MediaId(2)])))] #[case::include(generate!(MediaListParams, (include, vec![MediaId(1)])))] #[case::offset(generate!(MediaListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_media_mut.rs b/wp_api_integration_tests/tests/test_media_mut.rs index 1abd07803..0ace9a7fb 100644 --- a/wp_api_integration_tests/tests/test_media_mut.rs +++ b/wp_api_integration_tests/tests/test_media_mut.rs @@ -1,4 +1,5 @@ use macro_helper::generate_update_test; +use wp_api::date::WpDateString; use wp_api::{ media::{MediaCreateParams, MediaUpdateParams}, posts::{PostCommentStatus, PostPingStatus, PostStatus}, @@ -33,12 +34,16 @@ async fn delete_media() { RestoreServer::db().await; } -generate_update_test!(update_date, date, "2024-09-09T12:00:00".to_string()); +generate_update_test!( + update_date, + date, + WpDateString::new("2024-09-09T12:00:00".to_string()) +); generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00") ); generate_update_test!(update_slug, slug, "new_slug".to_string()); diff --git a/wp_api_integration_tests/tests/test_nav_menu_items_immut.rs b/wp_api_integration_tests/tests/test_nav_menu_items_immut.rs index 24c81105f..05801848b 100644 --- a/wp_api_integration_tests/tests/test_nav_menu_items_immut.rs +++ b/wp_api_integration_tests/tests/test_nav_menu_items_immut.rs @@ -108,10 +108,10 @@ fn nav_menu_item_id_for_retrieve_tests() -> NavMenuItemId { #[case::default(NavMenuItemListParams::default())] #[case::per_page(generate!(NavMenuItemListParams, (per_page, Some(5))))] #[case::search(generate!(NavMenuItemListParams, (search, Some("test".to_string()))))] -#[case::after(generate!(NavMenuItemListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] -#[case::modified_after(generate!(NavMenuItemListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] -#[case::before(generate!(NavMenuItemListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0000")))))] -#[case::modified_before(generate!(NavMenuItemListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0000")))))] +#[case::after(generate!(NavMenuItemListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] +#[case::modified_after(generate!(NavMenuItemListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] +#[case::before(generate!(NavMenuItemListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] +#[case::modified_before(generate!(NavMenuItemListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] #[case::exclude(generate!(NavMenuItemListParams, (exclude, vec![NavMenuItemId(1), NavMenuItemId(2)])))] #[case::include(generate!(NavMenuItemListParams, (include, vec![nav_menu_item_id_for_retrieve_tests()])))] #[case::offset(generate!(NavMenuItemListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_navigations_immut.rs b/wp_api_integration_tests/tests/test_navigations_immut.rs index 6572c93d5..6ecfbc7e2 100644 --- a/wp_api_integration_tests/tests/test_navigations_immut.rs +++ b/wp_api_integration_tests/tests/test_navigations_immut.rs @@ -107,10 +107,10 @@ fn navigation_id() -> NavigationId { #[case::page(generate!(NavigationListParams, (page, Some(1))))] #[case::per_page(generate!(NavigationListParams, (per_page, Some(3))))] #[case::search(generate!(NavigationListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(NavigationListParams, (after, Some("2020-08-14T17:00:00".to_string()))))] -#[case::modified_after(generate!(NavigationListParams, (modified_after, Some("2024-01-14T17:00:00".to_string()))))] -#[case::before(generate!(NavigationListParams, (before, Some("2023-08-14T17:00:00".to_string()))))] -#[case::modified_before(generate!(NavigationListParams, (modified_before, Some("2024-01-14T17:00:00".to_string()))))] +#[case::after(generate!(NavigationListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+00:00")))))] +#[case::modified_after(generate!(NavigationListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] +#[case::before(generate!(NavigationListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] +#[case::modified_before(generate!(NavigationListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] #[case::exclude(generate!(NavigationListParams, (exclude, vec![NavigationId(1), NavigationId(2)])))] #[case::include(generate!(NavigationListParams, (include, vec![NavigationId(1)])))] #[case::offset(generate!(NavigationListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_navigations_mut.rs b/wp_api_integration_tests/tests/test_navigations_mut.rs index 0f038694c..e9c44a5e1 100644 --- a/wp_api_integration_tests/tests/test_navigations_mut.rs +++ b/wp_api_integration_tests/tests/test_navigations_mut.rs @@ -1,4 +1,5 @@ use macro_helper::{generate_update_navigation_status_test, generate_update_test}; +use wp_api::date::WpDateString; use wp_api::navigations::{ NavigationCreateParams, NavigationId, NavigationStatus, NavigationUpdateParams, NavigationWithEditContext, @@ -86,20 +87,20 @@ async fn trash_navigation() { generate_update_test!( update_date, date, - "2024-09-09T12:00:00".to_string(), + WpDateString::new("2024-09-09T12:00:00".to_string()), |updated_navigation| { - assert_eq!(updated_navigation.date, "2024-09-09T12:00:00"); + assert_eq!(updated_navigation.date.value, "2024-09-09T12:00:00"); } ); generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00"), |updated_navigation| { assert_eq!( updated_navigation.date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00") ); } ); @@ -152,7 +153,7 @@ async fn update_status_to_future() { test_update_navigation( &NavigationUpdateParams { status: Some(NavigationStatus::Future), - date: Some("2026-09-09T12:00:00".to_string()), + date: Some(WpDateString::new("2026-09-09T12:00:00".to_string())), ..Default::default() }, |updated_navigation| { diff --git a/wp_api_integration_tests/tests/test_pages_immut.rs b/wp_api_integration_tests/tests/test_pages_immut.rs index b588ee1aa..2b7db0cee 100644 --- a/wp_api_integration_tests/tests/test_pages_immut.rs +++ b/wp_api_integration_tests/tests/test_pages_immut.rs @@ -199,12 +199,12 @@ async fn paginate_list_pages_with_edit_context(#[case] params: PostListParams) { #[case::page(generate!(PostListParams, (page, Some(1))))] #[case::per_page(generate!(PostListParams, (per_page, Some(3))))] #[case::search(generate!(PostListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(PostListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] -#[case::modified_after(generate!(PostListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] +#[case::after(generate!(PostListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] +#[case::modified_after(generate!(PostListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] #[case::author(generate!(PostListParams, (author, vec![FIRST_USER_ID, SECOND_USER_ID])))] #[case::author_exclude(generate!(PostListParams, (author_exclude, vec![SECOND_USER_ID])))] -#[case::before(generate!(PostListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0000")))))] -#[case::modified_before(generate!(PostListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0000")))))] +#[case::before(generate!(PostListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] +#[case::modified_before(generate!(PostListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] #[case::exclude(generate!(PostListParams, (exclude, vec![PostId(1), PostId(2)])))] #[case::include(generate!(PostListParams, (include, vec![PostId(1)])))] #[case::offset(generate!(PostListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_pages_mut.rs b/wp_api_integration_tests/tests/test_pages_mut.rs index ca044cf5a..49ff71821 100644 --- a/wp_api_integration_tests/tests/test_pages_mut.rs +++ b/wp_api_integration_tests/tests/test_pages_mut.rs @@ -1,4 +1,5 @@ use macro_helper::{generate_update_page_status_test, generate_update_test}; +use wp_api::date::WpDateString; use wp_api::posts::{ AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostFootnote, PostMeta, PostPingStatus, PostStatus, PostUpdateParams, @@ -172,9 +173,9 @@ async fn trash_page() { generate_update_test!( update_date, date, - "2024-09-09T12:00:00".to_string(), + WpDateString::new("2024-09-09T12:00:00".to_string()), |updated_page, updated_page_from_wp_cli| { - assert_eq!(updated_page.date, "2024-09-09T12:00:00"); + assert_eq!(updated_page.date.value, "2024-09-09T12:00:00"); assert_eq!(updated_page_from_wp_cli.date, "2024-09-09 12:00:00"); } ); @@ -182,11 +183,11 @@ generate_update_test!( generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00"), |updated_page, updated_page_from_wp_cli| { assert_eq!( updated_page.date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00") ); assert_eq!(updated_page_from_wp_cli.date_gmt, "2024-09-09 12:00:00"); } @@ -381,7 +382,7 @@ async fn update_status_to_future() { &PostUpdateParams { status: Some(PostStatus::Future), // Publish date has to be in the future - date: Some("2026-09-09T12:00:00".to_string()), + date: Some(WpDateString::new("2026-09-09T12:00:00".to_string())), ..Default::default() }, |updated_page, updated_page_from_wp_cli| { diff --git a/wp_api_integration_tests/tests/test_posts_immut.rs b/wp_api_integration_tests/tests/test_posts_immut.rs index 6b2753f7d..4182caede 100644 --- a/wp_api_integration_tests/tests/test_posts_immut.rs +++ b/wp_api_integration_tests/tests/test_posts_immut.rs @@ -280,12 +280,12 @@ async fn list_with_post_endpoint_type_using_default_params( #[case::page(generate!(PostListParams, (page, Some(1))))] #[case::per_page(generate!(PostListParams, (per_page, Some(3))))] #[case::search(generate!(PostListParams, (search, Some("foo".to_string()))))] -#[case::after(generate!(PostListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+0200")))))] -#[case::modified_after(generate!(PostListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0200")))))] +#[case::after(generate!(PostListParams, (after, Some(unwrapped_wp_gmt_date_time("2020-08-14T17:00:00+02:00")))))] +#[case::modified_after(generate!(PostListParams, (modified_after, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+02:00")))))] #[case::author(generate!(PostListParams, (author, vec![FIRST_USER_ID, SECOND_USER_ID])))] #[case::author_exclude(generate!(PostListParams, (author_exclude, vec![SECOND_USER_ID])))] -#[case::before(generate!(PostListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+0000")))))] -#[case::modified_before(generate!(PostListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+0000")))))] +#[case::before(generate!(PostListParams, (before, Some(unwrapped_wp_gmt_date_time("2023-08-14T17:00:00+00:00")))))] +#[case::modified_before(generate!(PostListParams, (modified_before, Some(unwrapped_wp_gmt_date_time("2024-01-14T17:00:00+00:00")))))] #[case::exclude(generate!(PostListParams, (exclude, vec![PostId(1), PostId(2)])))] #[case::include(generate!(PostListParams, (include, vec![PostId(1)])))] #[case::offset(generate!(PostListParams, (offset, Some(2))))] diff --git a/wp_api_integration_tests/tests/test_posts_mut.rs b/wp_api_integration_tests/tests/test_posts_mut.rs index 2520ac527..fbc42e6d6 100644 --- a/wp_api_integration_tests/tests/test_posts_mut.rs +++ b/wp_api_integration_tests/tests/test_posts_mut.rs @@ -2,6 +2,7 @@ use macro_helper::{ generate_update_post_format_test, generate_update_post_status_test, generate_update_test, }; use std::collections::HashMap; +use wp_api::date::WpDateString; use wp_api::posts::{ AnyPostWithEditContext, PostCommentStatus, PostCreateParams, PostFootnote, PostFormat, PostListParams, PostMeta, PostPingStatus, PostRetrieveParams, PostStatus, PostUpdateParams, @@ -284,9 +285,9 @@ async fn trash_post() { generate_update_test!( update_date, date, - "2024-09-09T12:00:00".to_string(), + WpDateString::new("2024-09-09T12:00:00".to_string()), |updated_post, updated_post_from_wp_cli| { - assert_eq!(updated_post.date, "2024-09-09T12:00:00"); + assert_eq!(updated_post.date.value, "2024-09-09T12:00:00"); assert_eq!(updated_post_from_wp_cli.date, "2024-09-09 12:00:00"); } ); @@ -294,11 +295,11 @@ generate_update_test!( generate_update_test!( update_date_gmt, date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000"), + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00"), |updated_post, updated_post_from_wp_cli| { assert_eq!( updated_post.date_gmt, - unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+0000") + unwrapped_wp_gmt_date_time("2024-09-09T12:00:00+00:00") ); assert_eq!(updated_post_from_wp_cli.date_gmt, "2024-09-09 12:00:00"); } @@ -561,7 +562,7 @@ async fn update_status_to_future() { &PostUpdateParams { status: Some(PostStatus::Future), // Publish date has to be in the future - date: Some("2026-09-09T12:00:00".to_string()), + date: Some(WpDateString::new("2026-09-09T12:00:00".to_string())), ..Default::default() }, |updated_post, updated_post_from_wp_cli| { diff --git a/wp_mobile/src/collection/media_metadata_collection.rs b/wp_mobile/src/collection/media_metadata_collection.rs index 4ca19db59..93ffdfb0c 100644 --- a/wp_mobile/src/collection/media_metadata_collection.rs +++ b/wp_mobile/src/collection/media_metadata_collection.rs @@ -416,7 +416,7 @@ impl MediaMetadataCollectionWithEditContext { fn media_to_item_input(media: &MediaWithEditContext) -> ListMetadataItemInput { ListMetadataItemInput { entity_id: media.id.0, - modified_gmt: Some(media.modified_gmt.to_string()), + modified_gmt: Some(media.modified_gmt), parent: media.post_id.map(|p| p.0), menu_order: None, } @@ -434,7 +434,7 @@ impl MediaMetadataCollectionWithEditContext { } else if let Some(metadata) = metadata_by_id.get(&id) { ListMetadataItemInput { entity_id: id, - modified_gmt: metadata.modified_gmt.as_ref().map(|d| d.to_string()), + modified_gmt: metadata.modified_gmt, parent: metadata.parent, menu_order: metadata.menu_order, } diff --git a/wp_mobile/src/collection/post_metadata_collection.rs b/wp_mobile/src/collection/post_metadata_collection.rs index 73e35e8d7..a387b3d4b 100644 --- a/wp_mobile/src/collection/post_metadata_collection.rs +++ b/wp_mobile/src/collection/post_metadata_collection.rs @@ -477,7 +477,7 @@ impl PostMetadataCollectionWithEditContext { fn post_to_item_input(post: &AnyPostWithEditContext) -> ListMetadataItemInput { ListMetadataItemInput { entity_id: post.id.0, - modified_gmt: Some(post.modified_gmt.to_string()), + modified_gmt: Some(post.modified_gmt), parent: post.parent.map(|p| p.0), menu_order: post.menu_order.map(|m| m as i64), } @@ -497,7 +497,7 @@ impl PostMetadataCollectionWithEditContext { } else if let Some(metadata) = metadata_by_id.get(&id) { ListMetadataItemInput { entity_id: id, - modified_gmt: metadata.modified_gmt.as_ref().map(|d| d.to_string()), + modified_gmt: metadata.modified_gmt, parent: metadata.parent, menu_order: metadata.menu_order, } diff --git a/wp_mobile/src/service/media.rs b/wp_mobile/src/service/media.rs index 07bb29959..0da98f92b 100644 --- a/wp_mobile/src/service/media.rs +++ b/wp_mobile/src/service/media.rs @@ -205,7 +205,8 @@ impl MediaService { /// /// A media item is considered stale if: /// 1. It's currently in `Fresh` state in the state store - /// 2. Its fetched `modified_gmt` differs from the cached `modified_gmt` in the database + /// 2. Its fetched `modified_gmt` differs from the cached `modified_gmt` in the database, + /// or the cached value is missing or unreadable pub(crate) fn find_stale_media_by_timestamp( &self, metadata: &[EntityMetadata], @@ -237,14 +238,14 @@ impl MediaService { metadata .iter() - .filter_map(|m| { - if let Some(fetched_modified) = &m.modified_gmt - && let Some(cached_modified) = cached_timestamps.get(&MediaId(m.id)) - && fetched_modified != cached_modified - { - Some(m.id) - } else { - None + .filter_map(|m| match cached_timestamps.get(&MediaId(m.id))? { + // The cached timestamp is missing or unreadable, so there is + // nothing to prove the item is current. Refetch it rather than + // leave it cached forever. + None => Some(m.id), + Some(cached_modified) => { + let fetched_modified = m.modified_gmt.as_ref()?; + (fetched_modified != cached_modified).then_some(m.id) } }) .collect() diff --git a/wp_mobile/src/service/metadata.rs b/wp_mobile/src/service/metadata.rs index ead8e1037..44894e7b8 100644 --- a/wp_mobile/src/service/metadata.rs +++ b/wp_mobile/src/service/metadata.rs @@ -1,5 +1,4 @@ use std::{future::Future, sync::Arc}; -use wp_api::prelude::WpGmtDateTime; use wp_mobile_cache::{ RowId, WpApiCache, db_types::db_site::DbSite, @@ -184,10 +183,12 @@ impl MetadataService { let metadata = items .into_iter() .map(|item| { - let modified_gmt = item - .modified_gmt - .and_then(|s| s.parse::().ok()); - EntityMetadata::new(item.entity_id, modified_gmt, item.parent, item.menu_order) + EntityMetadata::new( + item.entity_id, + item.modified_gmt, + item.parent, + item.menu_order, + ) }) .collect(); @@ -457,7 +458,7 @@ impl MetadataService { .iter() .map(|m| ListMetadataItemInput { entity_id: m.id, - modified_gmt: m.modified_gmt.as_ref().map(|dt| dt.to_string()), + modified_gmt: m.modified_gmt, parent: m.parent, menu_order: m.menu_order, }) @@ -597,7 +598,7 @@ impl MetadataService { .iter() .map(|m| ListMetadataItemInput { entity_id: m.id, - modified_gmt: m.modified_gmt.as_ref().map(|dt| dt.to_string()), + modified_gmt: m.modified_gmt, parent: m.parent, menu_order: m.menu_order, }) diff --git a/wp_mobile/src/service/mock_post_service.rs b/wp_mobile/src/service/mock_post_service.rs index 9aa592322..310ed8ad1 100644 --- a/wp_mobile/src/service/mock_post_service.rs +++ b/wp_mobile/src/service/mock_post_service.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; +use wp_api::date::WpDateString; use wp_api::posts::{ AnyPostWithEditContext, PostContentWithEditContext, PostGuidWithEditContext, PostId, PostStatus, PostTitleWithEditContext, @@ -132,14 +133,14 @@ fn create_test_post( ) -> AnyPostWithEditContext { AnyPostWithEditContext { id, - date: "2025-01-01T00:00:00".to_string(), + date: WpDateString::new("2025-01-01T00:00:00".to_string()), date_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), guid: PostGuidWithEditContext { raw: None, rendered: format!("https://example.com/?p={}", id.0), }, link: link.to_string(), - modified: "2025-01-01T00:00:00".to_string(), + modified: WpDateString::new("2025-01-01T00:00:00".to_string()), modified_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), slug: slug.to_string(), status: PostStatus::Publish, diff --git a/wp_mobile/src/service/posts.rs b/wp_mobile/src/service/posts.rs index ec9590017..760bb9869 100644 --- a/wp_mobile/src/service/posts.rs +++ b/wp_mobile/src/service/posts.rs @@ -213,7 +213,8 @@ impl PostService { /// /// A post is considered stale if: /// 1. It's currently in `Fresh` state in the state store - /// 2. Its fetched `modified_gmt` differs from the cached `modified_gmt` in the database + /// 2. Its fetched `modified_gmt` differs from the cached `modified_gmt` in the database, + /// or the cached value is missing or unreadable /// /// Returns empty vector if no stale posts found or if DB query fails. pub(crate) fn find_stale_posts_by_timestamp( @@ -250,14 +251,14 @@ impl PostService { // Compare timestamps and collect stale IDs metadata .iter() - .filter_map(|m| { - if let Some(fetched_modified) = &m.modified_gmt - && let Some(cached_modified) = cached_timestamps.get(&PostId(m.id)) - && fetched_modified != cached_modified - { - Some(m.id) - } else { - None + .filter_map(|m| match cached_timestamps.get(&PostId(m.id))? { + // The cached timestamp is missing or unreadable, so there is + // nothing to prove the post is current. Refetch it rather than + // leave it cached forever. + None => Some(m.id), + Some(cached_modified) => { + let fetched_modified = m.modified_gmt.as_ref()?; + (fetched_modified != cached_modified).then_some(m.id) } }) .collect() diff --git a/wp_mobile_cache/src/db_types/db_list_metadata.rs b/wp_mobile_cache/src/db_types/db_list_metadata.rs index 71915d9fb..9b4cc1cae 100644 --- a/wp_mobile_cache/src/db_types/db_list_metadata.rs +++ b/wp_mobile_cache/src/db_types/db_list_metadata.rs @@ -1,5 +1,6 @@ use crate::{ SqliteDbError, + db_types::helpers::parse_optional_datetime, db_types::row_ext::{ColumnIndex, RowExt}, list_metadata::{ DbListHeaderWithState, DbListMetadata, DbListMetadataItem, DbListMetadataState, ListState, @@ -78,7 +79,7 @@ impl DbListMetadataItem { row_id: row.get_column(Col::Rowid)?, list_metadata_id: row.get_column(Col::ListMetadataId)?, entity_id: row.get_column(Col::EntityId)?, - modified_gmt: row.get_column(Col::ModifiedGmt)?, + modified_gmt: parse_optional_datetime(row, Col::ModifiedGmt)?, parent: row.get_column(Col::Parent)?, menu_order: row.get_column(Col::MenuOrder)?, }) diff --git a/wp_mobile_cache/src/db_types/helpers.rs b/wp_mobile_cache/src/db_types/helpers.rs index 6d2beb3d5..311861ecb 100644 --- a/wp_mobile_cache/src/db_types/helpers.rs +++ b/wp_mobile_cache/src/db_types/helpers.rs @@ -5,6 +5,7 @@ use crate::{ use rusqlite::Row; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use wp_api::date::WpDateString; /// Helper to get a required ID wrapper type (e.g., PostId, UserId) from a row. pub fn get_id(row: &Row, column: C) -> Result @@ -16,6 +17,15 @@ where Ok(id.into()) } +/// Helper to get a required [`WpDateString`] from a string column. +pub fn get_date_string(row: &Row, column: C) -> Result +where + C: ColumnIndex, +{ + let value: String = row.get_column(column)?; + Ok(WpDateString::new(value)) +} + /// Helper to get an optional ID wrapper type from a row. pub fn get_optional_id(row: &Row, column: C) -> Result, SqliteDbError> where @@ -126,6 +136,20 @@ where .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse datetime: {}", e))) } +/// Helper to parse an optional DateTime-like type from a string column. +pub fn parse_optional_datetime(row: &Row, column: C) -> Result, SqliteDbError> +where + T: FromStr, + T::Err: std::fmt::Display, + C: ColumnIndex, +{ + let datetime_str: Option = row.get_column(column)?; + datetime_str + .map(|s| s.parse()) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse datetime: {}", e))) +} + /// Helper to deserialize a JSON array of ID wrapper types. /// This handles the case where we store `Vec` as `Vec` in JSON. pub fn deserialize_json_id_array( diff --git a/wp_mobile_cache/src/list_metadata.rs b/wp_mobile_cache/src/list_metadata.rs index d6d8a3293..643d03283 100644 --- a/wp_mobile_cache/src/list_metadata.rs +++ b/wp_mobile_cache/src/list_metadata.rs @@ -1,6 +1,7 @@ use crate::RowId; use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput}; use std::fmt; +use wp_api::date::WpGmtDateTime; /// Type-safe wrapper for list keys. /// @@ -96,7 +97,7 @@ pub struct DbListMetadataItem { /// Entity ID (post ID, comment ID, etc.) pub entity_id: i64, /// Last modified timestamp (for staleness detection) - pub modified_gmt: Option, + pub modified_gmt: Option, /// Parent entity ID (for hierarchical post types like pages) pub parent: Option, /// Menu order (for hierarchical post types) diff --git a/wp_mobile_cache/src/repository/list_metadata.rs b/wp_mobile_cache/src/repository/list_metadata.rs index bfd95288d..732b32517 100644 --- a/wp_mobile_cache/src/repository/list_metadata.rs +++ b/wp_mobile_cache/src/repository/list_metadata.rs @@ -7,6 +7,7 @@ use crate::{ }, repository::QueryExecutor, }; +use wp_api::date::WpGmtDateTime; /// Repository for managing list metadata in the database. /// @@ -388,7 +389,7 @@ impl ListMetadataRepository { [ Box::new(list_metadata_id), Box::new(item.entity_id), - Box::new(item.modified_gmt.clone()), + Box::new(item.modified_gmt.map(|dt| dt.to_string())), Box::new(item.parent), Box::new(item.menu_order), ] @@ -662,7 +663,7 @@ pub struct ListMetadataItemInput { /// Entity ID (post ID, comment ID, etc.) pub entity_id: i64, /// Last modified timestamp (for staleness detection) - pub modified_gmt: Option, + pub modified_gmt: Option, /// Parent entity ID (for hierarchical post types like pages) pub parent: Option, /// Menu order (for hierarchical post types) @@ -907,13 +908,13 @@ mod tests { let items = vec![ ListMetadataItemInput { entity_id: 100, - modified_gmt: Some("2024-01-01T00:00:00Z".to_string()), + modified_gmt: Some("2024-01-01T00:00:00Z".parse().expect("Test date is valid")), parent: Some(50), menu_order: Some(1), }, ListMetadataItemInput { entity_id: 200, - modified_gmt: Some("2024-01-02T00:00:00Z".to_string()), + modified_gmt: Some("2024-01-02T00:00:00Z".parse().expect("Test date is valid")), parent: Some(50), menu_order: Some(2), }, diff --git a/wp_mobile_cache/src/repository/media.rs b/wp_mobile_cache/src/repository/media.rs index 5f9767dff..b8e6d42b3 100644 --- a/wp_mobile_cache/src/repository/media.rs +++ b/wp_mobile_cache/src/repository/media.rs @@ -4,8 +4,8 @@ use crate::{ db_types::{ db_site::DbSite, helpers::{ - deserialize_json_value, get_id, get_optional_id, parse_datetime, parse_enum, - serialize_value_to_json, + deserialize_json_value, get_date_string, get_id, get_optional_id, parse_datetime, + parse_enum, serialize_value_to_json, }, media::{DbMediaWithEditContext, MediaEditContextColumn}, row_ext::RowExt, @@ -146,13 +146,15 @@ impl MediaRepository { /// Select `modified_gmt` timestamps for multiple media items by their WordPress media IDs. /// /// Lightweight query used for staleness detection; media not present in the cache are - /// omitted from the result. + /// omitted from the result. A cached item whose `modified_gmt` is absent or unreadable + /// maps to `None`, so a caller can tell it apart from one that isn't cached and decide + /// for itself rather than being handed silence. pub fn select_modified_gmt_by_ids( &self, executor: &impl QueryExecutor, site: &DbSite, media_ids: &[MediaId], - ) -> Result, SqliteDbError> { + ) -> Result>, SqliteDbError> { if media_ids.is_empty() { return Ok(HashMap::new()); } @@ -172,18 +174,15 @@ impl MediaRepository { let mut stmt = executor.prepare(&sql)?; let rows = stmt.query_map([site.row_id], |row| { let id: i64 = row.get(0)?; - let modified_gmt_str: String = row.get(1)?; + let modified_gmt_str: Option = row.get(1)?; Ok((id, modified_gmt_str)) })?; Ok(rows .filter_map(|row_result| { - row_result.ok().and_then(|(id, modified_gmt_str)| { - modified_gmt_str - .parse::() - .ok() - .map(|modified_gmt| (MediaId(id), modified_gmt)) - }) + let (id, modified_gmt_str) = row_result.ok()?; + let modified_gmt = modified_gmt_str.and_then(|s| s.parse::().ok()); + Some((MediaId(id), modified_gmt)) }) .collect()) } @@ -285,14 +284,14 @@ impl MediaContext for EditContext { let media = MediaWithEditContext { id: get_id(row, Id)?, - date: row.get_column(Date)?, + date: get_date_string(row, Date)?, date_gmt: parse_datetime(row, DateGmt)?, guid: PostGuidWithEditContext { raw: row.get_column(GuidRaw)?, rendered: row.get_column(GuidRendered)?, }, link: row.get_column(Link)?, - modified: row.get_column(Modified)?, + modified: get_date_string(row, Modified)?, modified_gmt: parse_datetime(row, ModifiedGmt)?, slug: row.get_column(Slug)?, status: parse_enum(row, Status)?, @@ -429,10 +428,10 @@ impl MediaRepository { rusqlite::named_params! { ":db_site_id": site.row_id, ":id": media.id.0, - ":date": media.date, + ":date": media.date.value, ":date_gmt": media.date_gmt.to_string(), ":link": media.link, - ":modified": media.modified, + ":modified": media.modified.value, ":modified_gmt": media.modified_gmt.to_string(), ":slug": media.slug, ":status": media.status.to_string(), diff --git a/wp_mobile_cache/src/repository/posts.rs b/wp_mobile_cache/src/repository/posts.rs index 05abd1c84..7c6928265 100644 --- a/wp_mobile_cache/src/repository/posts.rs +++ b/wp_mobile_cache/src/repository/posts.rs @@ -4,8 +4,9 @@ use crate::{ db_types::{ db_site::DbSite, helpers::{ - bool_to_integer, deserialize_json_value, get_id, get_optional_id, integer_to_bool, - parse_datetime, parse_enum, parse_optional_enum, serialize_value_to_json, + bool_to_integer, deserialize_json_value, get_date_string, get_id, get_optional_id, + integer_to_bool, parse_datetime, parse_enum, parse_optional_enum, + serialize_value_to_json, }, posts::{ DbAnyPostWithEditContext, DbAnyPostWithEmbedContext, DbAnyPostWithViewContext, @@ -314,22 +315,21 @@ impl PostRepository { /// This is a lightweight query used for staleness detection - it only fetches /// the `id` and `modified_gmt` columns without loading the full post data. /// - /// Returns a HashMap mapping post IDs to their cached `modified_gmt` timestamps. - /// Posts not found in the cache are simply omitted from the result. + /// Posts not found in the cache are omitted from the result. A cached post + /// whose `modified_gmt` is absent or unreadable maps to `None`, so a caller + /// can tell it apart from one that isn't cached and decide for itself + /// rather than being handed silence. /// /// # Arguments /// * `executor` - Database connection or transaction /// * `site` - The site to query posts for /// * `post_ids` - WordPress post IDs to look up - /// - /// # Returns - /// HashMap where keys are post IDs and values are their `modified_gmt` timestamps. pub fn select_modified_gmt_by_ids( &self, executor: &impl QueryExecutor, site: &DbSite, post_ids: &[PostId], - ) -> Result, SqliteDbError> { + ) -> Result>, SqliteDbError> { if post_ids.is_empty() { return Ok(HashMap::new()); } @@ -349,18 +349,15 @@ impl PostRepository { let mut stmt = executor.prepare(&sql)?; let rows = stmt.query_map([site.row_id], |row| { let id: i64 = row.get(0)?; - let modified_gmt_str: String = row.get(1)?; + let modified_gmt_str: Option = row.get(1)?; Ok((id, modified_gmt_str)) })?; Ok(rows .filter_map(|row_result| { - row_result.ok().and_then(|(id, modified_gmt_str)| { - modified_gmt_str - .parse::() - .ok() - .map(|modified_gmt| (PostId(id), modified_gmt)) - }) + let (id, modified_gmt_str) = row_result.ok()?; + let modified_gmt = modified_gmt_str.and_then(|s| s.parse::().ok()); + Some((PostId(id), modified_gmt)) }) .collect()) } @@ -462,14 +459,14 @@ impl PostContext for EditContext { let post = AnyPostWithEditContext { id: get_id(row, Id)?, - date: row.get_column(Date)?, + date: get_date_string(row, Date)?, date_gmt: parse_datetime(row, DateGmt)?, guid: PostGuidWithEditContext { raw: row.get_column(GuidRaw)?, rendered: row.get_column(GuidRendered)?, }, link: row.get_column(Link)?, - modified: row.get_column(Modified)?, + modified: get_date_string(row, Modified)?, modified_gmt: parse_datetime(row, ModifiedGmt)?, slug: row.get_column(Slug)?, status: parse_enum(row, Status)?, @@ -560,13 +557,13 @@ impl PostContext for ViewContext { let post = AnyPostWithViewContext { id: get_id(row, Id)?, - date: row.get_column(Date)?, + date: get_date_string(row, Date)?, date_gmt: parse_datetime(row, DateGmt)?, guid: PostGuidWithViewContext { rendered: row.get_column(GuidRendered)?, }, link: row.get_column(Link)?, - modified: row.get_column(Modified)?, + modified: get_date_string(row, Modified)?, modified_gmt: parse_datetime(row, ModifiedGmt)?, slug: row.get_column(Slug)?, status: parse_enum(row, Status)?, @@ -648,7 +645,7 @@ impl PostContext for EmbedContext { let post = AnyPostWithEmbedContext { id: get_id(row, Id)?, - date: row.get_column(Date)?, + date: get_date_string(row, Date)?, link: row.get_column(Link)?, slug: row.get_column(Slug)?, post_type: row.get_column(PostType)?, @@ -765,10 +762,10 @@ impl PostRepository { rusqlite::named_params! { ":db_site_id": site.row_id, ":id": post.id.0, - ":date": post.date, + ":date": post.date.value, ":date_gmt": post.date_gmt.to_string(), ":link": post.link, - ":modified": post.modified, + ":modified": post.modified.value, ":modified_gmt": post.modified_gmt.to_string(), ":slug": post.slug, ":status": post.status.to_string(), @@ -909,10 +906,10 @@ impl PostRepository { rusqlite::named_params! { ":db_site_id": site.row_id, ":id": post.id.0, - ":date": post.date, + ":date": post.date.value, ":date_gmt": post.date_gmt.to_string(), ":link": post.link, - ":modified": post.modified, + ":modified": post.modified.value, ":modified_gmt": post.modified_gmt.to_string(), ":slug": post.slug, ":status": post.status.to_string(), @@ -1029,7 +1026,7 @@ impl PostRepository { rusqlite::named_params! { ":db_site_id": site.row_id, ":id": post.id.0, - ":date": post.date, + ":date": post.date.value, ":link": post.link, ":slug": post.slug, ":post_type": post.post_type, @@ -1079,6 +1076,47 @@ mod tests { use rstest::*; use wp_api::posts::{AnyPostWithEditContext, PostStatus}; + /// A cached `modified_gmt` that can't be read maps to `None` instead of + /// dropping the row, so a caller can tell "cached but unreadable" apart + /// from "not cached at all". + /// + /// Dropping it made the staleness check treat the post as current, because + /// a missing entry there means "not stale" — so the row would never be + /// refetched and the stale copy stayed in the cache indefinitely. + #[rstest] + #[case::never_set_date("'-0001-11-30T00:00:00'")] + #[case::unparseable("'not a date'")] + fn test_unreadable_cached_modified_gmt_maps_to_none( + mut test_ctx: TestContext, + #[case] stored_value: &str, + ) { + let post = PostBuilder::minimal().build(); + let post_id = post.id; + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .expect("Failed to insert post"); + + test_ctx + .conn + .execute( + &format!("UPDATE posts_edit_context SET modified_gmt = {stored_value}"), + [], + ) + .expect("Failed to overwrite the cached timestamp"); + + let cached = test_ctx + .post_repo + .select_modified_gmt_by_ids(&test_ctx.conn, &test_ctx.site, &[post_id]) + .expect("Failed to select cached timestamps"); + + assert_eq!( + cached.get(&post_id), + Some(&None), + "an unreadable timestamp should be present as None, not missing" + ); + } + /// Verify that PostEditContextColumn enum values match the actual database schema. /// This test protects against column reordering in migrations breaking the positional index mapping. #[rstest] diff --git a/wp_mobile_cache/src/test_fixtures/media.rs b/wp_mobile_cache/src/test_fixtures/media.rs index e58f7105c..37a4b3210 100644 --- a/wp_mobile_cache/src/test_fixtures/media.rs +++ b/wp_mobile_cache/src/test_fixtures/media.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use wp_api::{ + date::WpDateString, media::{ MediaCaptionWithEditContext, MediaDescriptionWithEditContext, MediaDetails, MediaId, MediaStatus, MediaType, MediaWithEditContext, @@ -129,14 +130,14 @@ impl Default for MediaBuilder { fn create_minimal_media() -> MediaWithEditContext { MediaWithEditContext { id: MediaId(0), - date: "2026-01-01T00:00:00".into(), + date: WpDateString::new("2026-01-01T00:00:00".to_string()), date_gmt: "2026-01-01T00:00:00Z".parse().unwrap(), guid: PostGuidWithEditContext { raw: None, rendered: "https://example.com/?p=0".into(), }, link: "https://example.com/0".into(), - modified: "2026-01-01T00:00:00".into(), + modified: WpDateString::new("2026-01-01T00:00:00".to_string()), modified_gmt: "2026-01-01T00:00:00Z".parse().unwrap(), slug: "media-0".into(), status: MediaStatus::Inherit, diff --git a/wp_mobile_cache/src/test_fixtures/posts.rs b/wp_mobile_cache/src/test_fixtures/posts.rs index 4037830be..2a46db7b7 100644 --- a/wp_mobile_cache/src/test_fixtures/posts.rs +++ b/wp_mobile_cache/src/test_fixtures/posts.rs @@ -1,5 +1,6 @@ use std::sync::atomic::{AtomicI64, Ordering}; use wp_api::{ + date::WpDateString, media::MediaId, posts::{ AnyPostWithEditContext, PostContentWithEditContext, PostFootnote, PostGuidWithEditContext, @@ -177,14 +178,14 @@ impl Default for PostBuilder { fn create_minimal_post() -> AnyPostWithEditContext { AnyPostWithEditContext { id: PostId(1), - date: "2024-01-01T00:00:00".to_string(), + date: WpDateString::new("2024-01-01T00:00:00".to_string()), date_gmt: "2024-01-01T00:00:00Z".parse().unwrap(), guid: PostGuidWithEditContext { raw: None, rendered: "https://example.com/?p=1".to_string(), }, link: "https://example.com/minimal-post".to_string(), - modified: "2024-01-01T00:00:00".to_string(), + modified: WpDateString::new("2024-01-01T00:00:00".to_string()), modified_gmt: "2024-01-01T00:00:00Z".parse().unwrap(), slug: "minimal-post".to_string(), status: PostStatus::Publish, @@ -222,14 +223,14 @@ fn create_minimal_post() -> AnyPostWithEditContext { fn create_full_post() -> AnyPostWithEditContext { AnyPostWithEditContext { id: PostId(42), - date: "2024-01-15T10:30:00".to_string(), + date: WpDateString::new("2024-01-15T10:30:00".to_string()), date_gmt: "2024-01-15T10:30:00Z".parse().unwrap(), guid: PostGuidWithEditContext { raw: Some("https://example.com/?p=42".to_string()), rendered: "https://example.com/?p=42".to_string(), }, link: "https://example.com/full-post".to_string(), - modified: "2024-01-16T14:20:00".to_string(), + modified: WpDateString::new("2024-01-16T14:20:00".to_string()), modified_gmt: "2024-01-16T14:20:00Z".parse().unwrap(), slug: "full-post".to_string(), status: PostStatus::Draft, @@ -284,14 +285,14 @@ fn create_full_post() -> AnyPostWithEditContext { fn create_custom_post() -> AnyPostWithEditContext { AnyPostWithEditContext { id: PostId(42), - date: "2024-01-15T10:30:00".to_string(), + date: WpDateString::new("2024-01-15T10:30:00".to_string()), date_gmt: "2024-01-15T10:30:00Z".parse().unwrap(), guid: PostGuidWithEditContext { raw: Some("https://example.com/?p=42".to_string()), rendered: "https://example.com/?p=42".to_string(), }, link: "https://example.com/full-post".to_string(), - modified: "2024-01-16T14:20:00".to_string(), + modified: WpDateString::new("2024-01-16T14:20:00".to_string()), modified_gmt: "2024-01-16T14:20:00Z".parse().unwrap(), slug: "1000".to_string(), status: PostStatus::Draft, diff --git a/wp_mobile_integration_tests/tests/test_posts_mut.rs b/wp_mobile_integration_tests/tests/test_posts_mut.rs index 3a106af3d..c4630ec4d 100644 --- a/wp_mobile_integration_tests/tests/test_posts_mut.rs +++ b/wp_mobile_integration_tests/tests/test_posts_mut.rs @@ -1,3 +1,4 @@ +use wp_api::date::WpDateString; use wp_api::posts::{ PostCreateParams, PostId, PostStatus, PostUpdateParams, WpApiParamPostsOrderBy, }; @@ -43,7 +44,7 @@ async fn test_load_next_page_with_duplicate_items_all_fresh() { for i in 1..=5 { let params = PostCreateParams { title: Some(format!("Test Post {}", i)), - date: Some(format!("{}{:02}T12:00:00", base_date, i)), + date: Some(WpDateString::new(format!("{}{:02}T12:00:00", base_date, i))), status: Some(PostStatus::Future), ..Default::default() }; @@ -75,7 +76,7 @@ async fn test_load_next_page_with_duplicate_items_all_fresh() { assert_eq!(collection.current_page(), Some(1)); // Step 3: Create another future post - let new_post_date = format!("{}04T06:00:00", base_date); + let new_post_date = WpDateString::new(format!("{}04T06:00:00", base_date)); let new_post_params = PostCreateParams { title: Some("New Post That Pushes One to Page 2".to_string()), date: Some(new_post_date), diff --git a/wp_serde_helper/src/lib.rs b/wp_serde_helper/src/lib.rs index 28d58fcb2..26d4ab452 100644 --- a/wp_serde_helper/src/lib.rs +++ b/wp_serde_helper/src/lib.rs @@ -12,4 +12,6 @@ pub use numeric::*; pub use offset::*; pub use string::*; pub use vec::*; -pub use wp_serde_date::wp_utc_date_format; +pub use wp_serde_date::{ + WpDateTimeParseError, parse_wp_date_time, wp_date_time_from_timestamp, wp_utc_date_format, +}; diff --git a/wp_serde_helper/src/wp_serde_date.rs b/wp_serde_helper/src/wp_serde_date.rs index 14d31f9b2..063299d40 100644 --- a/wp_serde_helper/src/wp_serde_date.rs +++ b/wp_serde_helper/src/wp_serde_date.rs @@ -1,10 +1,159 @@ +use chrono::{DateTime, Datelike, NaiveDateTime, Utc}; +use std::fmt::Display; + // https://core.trac.wordpress.org/ticket/41032 const WP_DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S"; const MYSQL_DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; +/// The spellings in which the zero date reaches a client without parsing. +/// +/// A spelling that does parse is caught by its instant instead, against +/// [`ZERO_DATE_TIMESTAMP`]. These two never will: `0000-00-00 00:00:00` has a +/// month and day out of range, and the three-digit-year form isn't valid +/// RFC 3339. +const ZERO_DATE_SPELLINGS: [&str; 3] = ["0000-00-00", "-001-11-30", "-0001-11-30"]; + +/// The instant PHP derives from the zero date, as a unix timestamp. +const ZERO_DATE_TIMESTAMP: i64 = -62_169_984_000; + +/// How far a timezone conversion can move that instant. Offsets run from UTC-12 +/// to UTC+14, so anything landing within a day of it is the zero date rather +/// than a date somebody meant. +const ZERO_DATE_TOLERANCE_SECONDS: i64 = 24 * 60 * 60; + +/// Why a value could not be read as a WordPress datetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WpDateTimeParseError { + /// The value is MySQL's zero date, `0000-00-00 00:00:00`, which WordPress + /// stores to mean no datetime was ever written here. It is the schema + /// default for every datetime column, and core writes it deliberately — + /// a draft whose publish date should float until the post is published + /// gets exactly this — so it is a legal value rather than corruption. + /// + /// It seldom arrives as that literal. An endpoint that doesn't guard the + /// column hands it to PHP's formatter, whose lenient parser rolls the zero + /// month and day back into 30 November of 1 BCE, written with three or + /// four year digits depending on the format used. + /// + /// A field that can legitimately be unset is an `Option` whose + /// deserializer reads this as `None`. Every other read path treats it as + /// an error, because the only alternative is that 1 BCE instant, which is + /// indistinguishable from real data once parsed. + /// + /// The trade-off that choice carries: on a field that is *not* optional, + /// serde aborts the whole document, so one row with a zero date fails an + /// entire list response rather than losing one field. Whether a given + /// field needs to be optional depends on the endpoint — some guard the + /// column and send `null`, some format it unguarded and send this. If a + /// response starts failing to parse and the message names this error, the + /// fix is to make that field optional, not to loosen the parser. + NotSet, + /// The value matches none of the forms WordPress sends, or resolves to an + /// instant before year 1, which no WordPress datetime legitimately has. + Invalid, +} + +impl Display for WpDateTimeParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotSet => write!(f, "Date is WordPress's zero date"), + Self::Invalid => write!(f, "Invalid date format"), + } + } +} + +impl std::error::Error for WpDateTimeParseError {} + +/// Parse a datetime in any of the forms WordPress and WordPress.com send it: +/// with a timezone offset (`2026-08-06T09:15:49+00:00`), the offsetless +/// WordPress form (`2026-08-06T09:15:49`), that form with sub-second +/// precision, MySQL's (`2026-08-06 09:15:49`), and a unix timestamp. +/// +/// The offsetless forms are read as UTC, so only pass values already known to +/// be GMT. +/// +/// # Errors +/// +/// Returns [`WpDateTimeParseError::NotSet`] for WordPress's zero date, +/// and [`WpDateTimeParseError::Invalid`] if the value matches none of the +/// forms above. +pub fn parse_wp_date_time(s: &str) -> Result, WpDateTimeParseError> { + if ZERO_DATE_SPELLINGS + .iter() + .any(|spelling| s.starts_with(spelling)) + { + return Err(WpDateTimeParseError::NotSet); + } + + parse_known_format(s) + .ok_or(WpDateTimeParseError::Invalid) + .and_then(reject_instant_no_wp_date_has) +} + +/// Read a unix timestamp as a WordPress datetime, holding it to the same rules +/// as a string value. +/// +/// # Errors +/// +/// As [`parse_wp_date_time`]. +pub fn wp_date_time_from_timestamp(seconds: i64) -> Result, WpDateTimeParseError> { + DateTime::::from_timestamp(seconds, 0) + .ok_or(WpDateTimeParseError::Invalid) + .and_then(reject_instant_no_wp_date_has) +} + +fn parse_known_format(s: &str) -> Option> { + let from_naive = |dt| Some(DateTime::::from_naive_utc_and_offset(dt, Utc)); + + // WP.org REST API Format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, WP_DATE_FORMAT) { + return from_naive(dt); + } + + // ISO-8601 + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + + // Unix Timestamp (wrapped in a string) + if let Ok(timestamp) = s.parse::() { + return DateTime::::from_timestamp(timestamp, 0); + } + + // MySQL format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, MYSQL_DATE_FORMAT) { + return from_naive(dt); + } + + // WP format with sub-second precision + NaiveDateTime::parse_from_str(s, &format!("{WP_DATE_FORMAT}.%f")) + .ok() + .and_then(from_naive) +} + +/// Reject an instant that parsed cleanly but that no WordPress datetime can +/// hold, so it never reaches a caller looking like real data. +/// +/// The zero date is matched on a window rather than the exact instant, because +/// an endpoint that converts it out of the site's timezone before formatting +/// shifts it by that offset — far enough to land on a neighbouring day. +fn reject_instant_no_wp_date_has( + date_time: DateTime, +) -> Result, WpDateTimeParseError> { + if (date_time.timestamp() - ZERO_DATE_TIMESTAMP).abs() <= ZERO_DATE_TOLERANCE_SECONDS { + return Err(WpDateTimeParseError::NotSet); + } + + if date_time.year() < 1 { + return Err(WpDateTimeParseError::Invalid); + } + + Ok(date_time) +} + pub mod wp_utc_date_format { - use super::{MYSQL_DATE_FORMAT, WP_DATE_FORMAT}; - use chrono::{DateTime, NaiveDateTime, Utc}; + use super::{WP_DATE_FORMAT, parse_wp_date_time, wp_date_time_from_timestamp}; + use chrono::{DateTime, Utc}; use serde::{self, Deserialize, Deserializer, Serializer}; pub fn serialize(date: &DateTime, serializer: S) -> Result @@ -19,46 +168,10 @@ pub mod wp_utc_date_format { D: Deserializer<'de>, { match DateRepresentation::deserialize(deserializer)? { - DateRepresentation::Int(timestamp) => { - if let Some(dt) = DateTime::::from_timestamp(timestamp, 0) { - return Ok(dt); - } - - Err(serde::de::Error::custom(format!( - "Invalid date : {timestamp}" - ))) - } + DateRepresentation::Int(timestamp) => wp_date_time_from_timestamp(timestamp) + .map_err(|e| serde::de::Error::custom(format!("{e}: {timestamp}"))), DateRepresentation::String(s) => { - // WP.org REST API Format - if let Ok(dt) = NaiveDateTime::parse_from_str(&s, WP_DATE_FORMAT) { - return Ok(DateTime::::from_naive_utc_and_offset(dt, Utc)); - } - - // ISO-8601 - if let Ok(dt) = DateTime::parse_from_rfc3339(&s) { - return Ok(dt.with_timezone(&Utc)); - } - - // Unix Timestamp (wrapped in a string) - if let Ok(timestamp) = s.parse::() - && let Some(dt) = DateTime::::from_timestamp(timestamp, 0) - { - return Ok(dt); - } - - // MySQL format - if let Ok(dt) = NaiveDateTime::parse_from_str(&s, MYSQL_DATE_FORMAT) { - return Ok(DateTime::::from_naive_utc_and_offset(dt, Utc)); - } - - // WP format with sub-second precision - if let Ok(dt) = NaiveDateTime::parse_from_str(&s, &format!("{WP_DATE_FORMAT}.%f")) { - return Ok(DateTime::::from_naive_utc_and_offset(dt, Utc)); - } - - Err(serde::de::Error::custom(format!( - "Invalid date format: {s}" - ))) + parse_wp_date_time(&s).map_err(|e| serde::de::Error::custom(format!("{e}: {s}"))) } } } @@ -116,4 +229,63 @@ mod tests { "Expected error for invalid date" ); } + + /// WordPress's zero date is recognised as such in each spelling it + /// arrives in, rather than read as a datetime. + /// + /// The offsetless and MySQL-shaped spellings used to parse cleanly and + /// return 30 November of 1 BCE as a real instant; the offset-bearing ones + /// were rejected. One value, two behaviours, decided by which format the + /// endpoint used. + #[rstest] + #[case::zero_date_column("0000-00-00 00:00:00")] + #[case::zero_date_column_iso("0000-00-00T00:00:00")] + #[case::zero_date_column_with_offset("0000-00-00T00:00:00+00:00")] + #[case::three_digit_year_with_offset("-001-11-30T00:00:00+00:00")] + #[case::four_digit_year_with_offset("-0001-11-30T00:00:00+00:00")] + #[case::three_digit_year("-001-11-30T00:00:00")] + #[case::four_digit_year("-0001-11-30T00:00:00")] + #[case::three_digit_year_mysql("-001-11-30 00:00:00")] + #[case::four_digit_year_mysql("-0001-11-30 00:00:00")] + #[case::unix_timestamp("-62169984000")] + // An endpoint that converts the zero date out of the site's timezone before + // formatting shifts the instant, far enough to land on a neighbouring day. + // `Europe/Berlin` to UTC gives the first of these. + #[case::shifted_west("-0001-11-29 23:06:32")] + #[case::shifted_furthest_west("-0001-11-29 10:00:00")] + #[case::shifted_furthest_east("-0001-11-30 14:00:00")] + fn test_zero_date_is_recognised(#[case] value: &str) { + assert_eq!( + parse_wp_date_time(value), + Err(WpDateTimeParseError::NotSet), + "{value} is WordPress's zero date" + ); + } + + /// An instant before year 1 that isn't the zero date is malformed, + /// not absent — the two are different failures and stay distinguishable. + #[rstest] + #[case::bce("-0500-01-01T00:00:00")] + #[case::year_zero("0000-01-01T00:00:00")] + fn test_instant_before_year_one_is_invalid(#[case] value: &str) { + assert_eq!( + parse_wp_date_time(value), + Err(WpDateTimeParseError::Invalid), + "no WordPress date is before year 1" + ); + } + + /// A field that isn't an `Option` has no way to say "absent", so the + /// zero date has to fail rather than resolve to an instant. + #[rstest] + #[case::string(r#""0000-00-00 00:00:00""#)] + #[case::offsetless_string(r#""-0001-11-30T00:00:00""#)] + #[case::timestamp("-62169984000")] + fn test_never_set_date_fails_a_required_field(#[case] date_string: &str) { + let json_str = format!("{{\"wp_utc_date_time\": {date_string}}}"); + assert!( + serde_json::from_str::(&json_str).is_err(), + "Expected error for WordPress's zero date" + ); + } }