diff --git a/Cargo.lock b/Cargo.lock index b9829c4a..34c49d75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,8 +1032,6 @@ dependencies = [ [[package]] name = "calcard" version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f" dependencies = [ "ahash", "chrono", diff --git a/Cargo.toml b/Cargo.toml index fc0affb3..645c0a08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,12 +93,17 @@ rpath = false # vendor/sieve-rs is the published 0.7.3 with those renamed; see its # VENDORED.md. Re-vendor when the version in Cargo.lock moves. # +# inbuxa: vendor/calcard is the published 0.3.14 with one work budget shared +# by all the recurrence rules an expansion runs; see its VENDORED.md. +# Re-vendor when the version in Cargo.lock moves. +# # inbuxa: hickory and opentelemetry are upstream's patched branches, vendored # at the commits upstream pins so builds never fetch from upstream's # repositories; see vendor/hickory-dns/VENDORED.md and # vendor/opentelemetry-rust/VENDORED.md. [patch.crates-io] sieve-rs = { path = "vendor/sieve-rs" } +calcard = { path = "vendor/calcard" } hickory-proto = { path = "vendor/hickory-dns/hickory-proto" } hickory-net = { path = "vendor/hickory-dns/hickory-net" } hickory-resolver = { path = "vendor/hickory-dns/hickory-resolver" } diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index 0084a9c8..3d6cd1e4 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -7,12 +7,13 @@ */ use crate::Directory; -use crate::backend::oidc::lookup::fetch_jwks_keys; +use crate::backend::oidc::lookup::{fetch_jwks_keys, read_body}; use crate::backend::oidc::{ - CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory, + CachedKey, DiscoveryDocument, Introspection, JwksCache, OidcConfig, OidcDiscovery, OidcError, + OpaqueTokens, OpenIdDirectory, }; use ahash::AHashMap; -use registry::schema::structs; +use registry::schema::{enums::OidcOpaqueTokens, structs}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; @@ -22,8 +23,57 @@ use utils::Client; const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30); const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3); +/// inbuxa: http-directory-services-13: what a bearer token that isn't a JWT +/// signs in by. Unset, as on a new directory: introspection when an +/// introspection endpoint is set, else nothing. A directory saved before the +/// setting existed reads as userinfo, which it always used. +pub fn opaque_token_policy(config: &structs::OidcDirectory) -> OidcOpaqueTokens { + config + .opaque_tokens + .unwrap_or(if config.introspection_url.is_some() { + OidcOpaqueTokens::Introspection + } else { + OidcOpaqueTokens::Reject + }) +} + impl OpenIdDirectory { pub async fn open(config: structs::OidcDirectory) -> Result { + // inbuxa: http-directory-services-13: only a directory saved before + // the audience was required has none; it keeps working, with a + // warning every time it loads + if config.require_audience.is_none() { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issuer_url.clone(), + Details = config.description.clone(), + Reason = "No required audience is set, so tokens this provider issued to any \ + other application sign in. Set requireAudience to the client ID \ + registered for this server; a later release will require it." + ); + } + + let opaque_tokens = match opaque_token_policy(&config) { + OidcOpaqueTokens::Reject => OpaqueTokens::Reject, + OidcOpaqueTokens::Userinfo => OpaqueTokens::Userinfo, + OidcOpaqueTokens::Introspection => OpaqueTokens::Introspection(Introspection { + url: config.introspection_url, + client_id: config.introspection_client_id.ok_or_else(|| { + "Opaque tokens are checked by introspection, which needs a client ID" + .to_string() + })?, + client_secret: config + .introspection_client_secret + .secret() + .await? + .map(|secret| secret.into_owned()) + .ok_or_else(|| { + "Opaque tokens are checked by introspection, which needs a client secret" + .to_string() + })?, + }), + }; + Self::new(OidcConfig { issue_url: config.issuer_url, require_aud: config.require_audience, @@ -32,13 +82,14 @@ impl OpenIdDirectory { claim_name: config.claim_name, claim_groups: config.claim_groups, default_domain: config.username_domain, + opaque_tokens, }) .await .map(Directory::OpenId) .map_err(|err| err.to_string()) } - pub async fn new(config: OidcConfig) -> Result { + pub async fn new(mut config: OidcConfig) -> Result { let http = utils::http::http_client_builder(false) .user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types .timeout(Duration::from_secs(30)) @@ -64,6 +115,19 @@ impl OpenIdDirectory { } }; + // inbuxa: http-directory-services-13 + if let OpaqueTokens::Introspection(introspection) = &mut config.opaque_tokens + && introspection.url.is_none() + { + introspection.url = Some(document.introspection_endpoint.clone().ok_or_else(|| { + OidcError::Config( + "Opaque tokens are checked by introspection, but no introspection endpoint \ + is set and the provider's discovery document names none" + .to_string(), + ) + })?); + } + Ok(Self { discovery: OidcDiscovery { url: config.issue_url.clone(), @@ -86,16 +150,16 @@ impl OpenIdDirectory { "{}/.well-known/openid-configuration", config.issue_url.trim_end_matches('/') ); - let discovery_bytes = http - .get(&discovery_url) - .send() - .await - .map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))? - .error_for_status() - .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))? - .bytes() - .await - .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?; + let discovery_bytes = read_body( + http.get(&discovery_url) + .send() + .await + .map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?, + "Discovery", + ) + .await?; let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes) .map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?; @@ -177,3 +241,35 @@ impl OpenIdDirectory { Ok((discovery, keys)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_directories_refuse_opaque_tokens_unless_they_name_an_endpoint() { + let new = structs::OidcDirectory::default(); + assert_eq!(opaque_token_policy(&new), OidcOpaqueTokens::Reject); + assert_eq!( + opaque_token_policy(&structs::OidcDirectory { + introspection_url: Some("https://idp.example.org/introspect".to_string()), + ..new.clone() + }), + OidcOpaqueTokens::Introspection + ); + for chosen in [ + OidcOpaqueTokens::Reject, + OidcOpaqueTokens::Userinfo, + OidcOpaqueTokens::Introspection, + ] { + assert_eq!( + opaque_token_policy(&structs::OidcDirectory { + opaque_tokens: Some(chosen), + introspection_url: Some("https://idp.example.org/introspect".to_string()), + ..new.clone() + }), + chosen + ); + } + } +} diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 1f78b4fb..2899de66 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -8,11 +8,12 @@ use crate::{ Account, Credentials, - backend::oidc::{CachedKey, OidcError, OpenIdDirectory}, + backend::oidc::{CachedKey, Introspection, OidcError, OpaqueTokens, OpenIdDirectory}, }; use ahash::AHashMap; use jsonwebtoken::{ Algorithm, DecodingKey, Header, Validation, decode, decode_header, + errors::ErrorKind, jwk::{self, JwkSet}, }; use reqwest::Client; @@ -21,6 +22,14 @@ use std::time::Instant; use std::{sync::Arc, time::Duration}; use trc::AuthEvent; +/// How often the provider's keys may be fetched again for a token they don't +/// verify. +const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(300); + +/// inbuxa: http-directory-services-13: what a provider sends back is read up +/// to this much. +const MAX_RESPONSE_SIZE: usize = 1024 * 1024; + impl OpenIdDirectory { pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { match credentials { @@ -59,81 +68,211 @@ impl OpenIdDirectory { )); } - let candidates = self.get_key(header.kid.as_deref()).await?; - let mut last_err = None; - for cached in &candidates { - let dk = &cached.decoding_key; - let alg = cached.algorithm; - let mut validation = Validation::new(alg); + let mut candidates = self.get_key(header.kid.as_deref()).await?; + let mut refreshed = false; + loop { + let mut last_err = None; + // inbuxa: whether every key failed only for being the wrong key + let mut wrong_keys = true; + for cached in &candidates { + let dk = &cached.decoding_key; + let alg = cached.algorithm; + let mut validation = Validation::new(alg); - if let Some(aud) = &self.config.require_aud { - validation.set_audience(&[aud]); - } else { - validation.validate_aud = false; + if let Some(aud) = &self.config.require_aud { + validation.set_audience(&[aud]); + } else { + validation.validate_aud = false; + } + + validation.set_issuer(&[&self.discovery.document.issuer]); + validation.leeway = 60; + + match decode::(token, dk, &validation) { + Ok(token_data) => { + if self.config.require_aud.is_some() + && token_data.claims.get("aud").is_none() + { + last_err = Some(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidAudience, + )); + wrong_keys = false; + continue; + } + + check_token_type(&token_data.claims)?; + self.validate_scopes(&token_data.claims)?; + + return self.account_from_claims(token, token_data.claims).await; + } + Err(e) => { + wrong_keys &= matches!( + e.kind(), + ErrorKind::InvalidSignature | ErrorKind::InvalidAlgorithm + ); + last_err = Some(e); + } + } } - validation.set_issuer(&[&self.discovery.document.issuer]); - validation.leeway = 60; + // inbuxa: http-directory-services-13: a token without a kid that + // no cached key verifies may be signed with a key the provider + // rotated in, so the keys are fetched again, once, as for an + // unknown kid (and as rarely) + if header.kid.is_none() + && !refreshed + && wrong_keys + && let Some(keys) = self.refresh_keys().await + { + candidates = keys; + refreshed = true; + continue; + } - match decode::(token, dk, &validation) { - Ok(token_data) => { - if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() { - last_err = Some(jsonwebtoken::errors::Error::from( - jsonwebtoken::errors::ErrorKind::InvalidAudience, - )); - continue; - } + return Err(OidcError::TokenValidation(format!( + "JWT validation failed: {}", + last_err.map(|e| e.to_string()).unwrap_or_default() + ))); + } + } - check_token_type(&token_data.claims)?; - self.validate_scopes(&token_data.claims)?; + /// inbuxa: the account a validated token's claims name, with what they + /// lack taken from the user info endpoint (shared by JWTs and + /// introspected tokens). + async fn account_from_claims( + &self, + token: &str, + mut claims: serde_json::Value, + ) -> Result { + let missing_username = is_claim_missing(&claims, Some(&self.config.claim_email)); + let jwt_email = self.resolve_email(&claims).ok(); + let missing_profile = is_claim_missing(&claims, self.config.claim_name.as_ref()) + || is_claim_missing(&claims, self.config.claim_groups.as_ref()); - let mut claims = token_data.claims; - let missing_username = - is_claim_missing(&claims, Some(&self.config.claim_email)); - let jwt_email = self.resolve_email(&claims).ok(); - let missing_profile = - is_claim_missing(&claims, self.config.claim_name.as_ref()) - || is_claim_missing(&claims, self.config.claim_groups.as_ref()); - - if jwt_email.is_none() || missing_username || missing_profile { - match self.fetch_userinfo(token).await { - Ok(userinfo) => { - if let (Some(base), Value::Object(extra)) = - (claims.as_object_mut(), userinfo) - { - for (key, value) in extra { - if base.get(&key).is_none_or(Value::is_null) { - base.insert(key, value); - } - } - } + if jwt_email.is_none() || missing_username || missing_profile { + match self.fetch_userinfo(token).await { + Ok(userinfo) => { + if let (Some(base), Value::Object(extra)) = (claims.as_object_mut(), userinfo) { + for (key, value) in extra { + if base.get(&key).is_none_or(Value::is_null) { + base.insert(key, value); } - Err(err) if jwt_email.is_none() => return Err(err), - Err(_) => {} } } - - let email = match jwt_email { - Some(email) if !missing_username => email, - _ => self.resolve_email(&claims)?, - }; - return self.build_account(email, &claims); - } - Err(e) => { - last_err = Some(e); } + Err(err) if jwt_email.is_none() => return Err(err), + Err(_) => {} } } - Err(OidcError::TokenValidation(format!( - "JWT validation failed: {}", - last_err.map(|e| e.to_string()).unwrap_or_default() - ))) + let email = match jwt_email { + Some(email) if !missing_username => email, + _ => self.resolve_email(&claims)?, + }; + self.build_account(email, &claims) } async fn authenticate_opaque(&self, token: &str) -> Result { - let claims = self.fetch_userinfo(token).await?; - self.build_account(self.resolve_email(&claims)?, &claims) + // inbuxa: http-directory-services-13: by the directory's policy + match &self.config.opaque_tokens { + OpaqueTokens::Reject => Err(OidcError::TokenValidation( + "This directory accepts only JWT access tokens".to_string(), + )), + OpaqueTokens::Userinfo => { + let claims = self.fetch_userinfo(token).await?; + self.build_account(self.resolve_email(&claims)?, &claims) + } + OpaqueTokens::Introspection(introspection) => { + let claims = self.introspect(token, introspection).await?; + check_token_type(&claims)?; + self.validate_scopes(&claims)?; + self.account_from_claims(token, claims).await + } + } + } + + /// inbuxa: http-directory-services-13: asks the provider about a token + /// (RFC 7662), and returns what it says only for an active token it + /// issued, for the required audience when one is set. + async fn introspect( + &self, + token: &str, + introspection: &Introspection, + ) -> Result { + let url = introspection + .url + .as_deref() + .ok_or_else(|| OidcError::Config("No introspection endpoint is known".to_string()))?; + let resp = self + .http + .post(url) + // RFC 6749, section 2.3.1: both form-encoded first + .basic_auth( + form_encode(&introspection.client_id), + Some(form_encode(&introspection.client_secret)), + ) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(format!( + "token={}&token_type_hint=access_token", + form_encode(token) + )) + .send() + .await + .map_err(|e| OidcError::Network(format!("Introspection request failed: {e}")))?; + + // A refusal here is of this server's client credentials, not the token + let status = resp.status(); + if !status.is_success() { + return Err(OidcError::Provider(format!( + "Introspection returned HTTP {status}" + ))); + } + let bytes = read_body(resp, "Introspection").await?; + let claims = serde_json::from_slice::(&bytes) + .map_err(|e| OidcError::Provider(format!("Introspection JSON parse error: {e}")))?; + + if claims.get("active") != Some(&Value::Bool(true)) { + return Err(OidcError::AuthorizationFailed( + "The provider says the token is not active".to_string(), + )); + } + let issuer = self.discovery.document.issuer.trim_end_matches('/'); + if claims + .get("iss") + .and_then(|iss| iss.as_str()) + .is_none_or(|iss| iss.trim_end_matches('/') != issuer) + { + return Err(OidcError::TokenValidation( + "The token was not issued by this directory's provider".to_string(), + )); + } + if let Some(audience) = &self.config.require_aud { + let found = match claims.get("aud") { + Some(Value::String(aud)) => aud == audience, + Some(Value::Array(auds)) => auds.iter().any(|aud| aud.as_str() == Some(audience)), + _ => false, + }; + if !found { + return Err(OidcError::TokenValidation( + "The token was not issued for the required audience".to_string(), + )); + } + } + if let Some(exp) = claims.get("exp").and_then(|exp| exp.as_u64()) + && exp + 60 + < std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |now| now.as_secs()) + { + return Err(OidcError::TokenValidation( + "The token has expired".to_string(), + )); + } + + Ok(claims) } async fn get_key(&self, kid: Option<&str>) -> Result>, OidcError> { @@ -145,7 +284,7 @@ impl OpenIdDirectory { return Ok(vec![cached.clone()]); } - if guard.last_updated.elapsed() < Duration::from_secs(300) { + if guard.last_updated.elapsed() < KEY_REFRESH_INTERVAL { return Err(OidcError::TokenValidation("Unknown key id".to_string())); } } else { @@ -184,6 +323,35 @@ impl OpenIdDirectory { } } + /// inbuxa: every key, fetched again unless that was tried within + /// `KEY_REFRESH_INTERVAL`; None when it was, or the fetch failed (a + /// failed fetch also waits out the interval, so tokens can't make the + /// server hammer a struggling provider). + async fn refresh_keys(&self) -> Option>> { + { + let mut guard = self.cache.write().await; + if guard.last_updated.elapsed() < KEY_REFRESH_INTERVAL { + return None; + } + guard.last_updated = Instant::now(); + } + match fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await { + Ok(keys) => { + let all = keys.values().cloned().collect(); + self.cache.write().await.keys = keys; + Some(all) + } + Err(err) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = self.discovery.document.jwks_uri.clone(), + Reason = format!("JWKS refresh failed: {err}") + ); + None + } + } + } + async fn fetch_userinfo(&self, token: &str) -> Result { let resp = self .http @@ -195,7 +363,10 @@ impl OpenIdDirectory { let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - let reason = resp.text().await.unwrap_or_default(); + let reason = read_body(resp, "UserInfo") + .await + .map(|body| String::from_utf8_lossy(&body).into_owned()) + .unwrap_or_default(); return Err(OidcError::AuthorizationFailed(format!( "Token rejected by UserInfo endpoint with status {status}: {reason}" ))); @@ -206,10 +377,7 @@ impl OpenIdDirectory { ))); } - let bytes = resp - .bytes() - .await - .map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?; + let bytes = read_body(resp, "UserInfo").await?; serde_json::from_slice::(&bytes) .map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}"))) @@ -296,16 +464,16 @@ pub(super) async fn fetch_jwks_keys( http: &Client, jwks_uri: &str, ) -> Result>, OidcError> { - let jwks_bytes = http - .get(jwks_uri) - .send() - .await - .map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))? - .error_for_status() - .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))? - .bytes() - .await - .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?; + let jwks_bytes = read_body( + http.get(jwks_uri) + .send() + .await + .map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?, + "JWKS", + ) + .await?; let jwks: JwkSet = serde_json::from_slice(&jwks_bytes) .map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?; @@ -409,6 +577,52 @@ pub(super) async fn fetch_jwks_keys( Ok(map) } +/// inbuxa: http-directory-services-13: a response body, refused once it +/// passes `MAX_RESPONSE_SIZE` instead of read whole. +pub(super) async fn read_body( + mut resp: reqwest::Response, + what: &str, +) -> Result, OidcError> { + let too_large = || { + OidcError::Provider(format!( + "{what} response is larger than {MAX_RESPONSE_SIZE} bytes" + )) + }; + if resp + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_SIZE as u64) + { + return Err(too_large()); + } + let mut body = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| OidcError::Provider(format!("{what} HTTP error: {e}")))? + { + if body.len() + chunk.len() > MAX_RESPONSE_SIZE { + return Err(too_large()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// inbuxa: application/x-www-form-urlencoded, for the introspection request. +fn form_encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => { + out.push(byte as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + fn extract_scopes(claims: &serde_json::Value) -> Vec { match claims.get("scope") { Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(), @@ -482,6 +696,19 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= -----END PUBLIC KEY-----"; fn test_directory() -> OpenIdDirectory { + test_directory_at("https://idp.example.org") + } + + /// The test directory, with its endpoints at `base`. + fn test_directory_at(base: &str) -> OpenIdDirectory { + test_directory_with(base, None, OpaqueTokens::Reject) + } + + fn test_directory_with( + base: &str, + require_aud: Option<&str>, + opaque_tokens: OpaqueTokens, + ) -> OpenIdDirectory { let mut keys = AHashMap::new(); keys.insert( "test".to_string(), @@ -493,22 +720,24 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= OpenIdDirectory { config: crate::backend::oidc::OidcConfig { issue_url: "https://idp.example.org".to_string(), - require_aud: None, + require_aud: require_aud.map(str::to_string), require_scopes: Vec::new(), claim_email: "email".to_string(), claim_name: None, claim_groups: None, default_domain: None, + opaque_tokens, }, discovery: crate::backend::oidc::OidcDiscovery { url: "https://idp.example.org".to_string(), document: crate::backend::oidc::DiscoveryDocument { issuer: "https://idp.example.org".to_string(), - jwks_uri: "https://idp.example.org/jwks".to_string(), - userinfo_endpoint: "https://idp.example.org/userinfo".to_string(), - token_endpoint: "https://idp.example.org/token".to_string(), - authorization_endpoint: "https://idp.example.org/auth".to_string(), + jwks_uri: format!("{base}/jwks"), + userinfo_endpoint: format!("{base}/userinfo"), + token_endpoint: format!("{base}/token"), + authorization_endpoint: format!("{base}/auth"), end_session_endpoint: None, + introspection_endpoint: None, scopes_supported: None, claims_supported: None, code_challenge_methods_supported: None, @@ -543,6 +772,52 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= .unwrap() } + /// A provider on 127.0.0.1 that answers every request with what + /// `answer` returns for it (the request line, headers and body). + async fn provider(answer: impl Fn(&str) -> (u16, String) + Send + Sync + 'static) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let answer = Arc::new(answer); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let answer = answer.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let read = stream.read(&mut buf).await.unwrap_or(0); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= end + 4 + length { + break; + } + } + } + let (status, body) = answer(&String::from_utf8_lossy(&request)); + // No Content-Length: the body ends when the connection does + let head = format!( + "HTTP/1.1 {status} Status\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(body.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + base + } + async fn sign_in(directory: &OpenIdDirectory, token: String) -> trc::Result { directory .authenticate(&Credentials::Bearer { @@ -587,6 +862,249 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= } } + // http-directory-services-13: any token the provider issued, to any + // application, passes its user info endpoint, so a new directory + // refuses tokens that aren't JWTs + #[tokio::test] + async fn new_directories_refuse_opaque_tokens() { + let base = provider(|_| (200, json!({"email": "john@example.org"}).to_string())).await; + let directory = test_directory_at(&base); + assert!( + sign_in(&directory, "an-opaque-token".to_string()) + .await + .is_err(), + "an opaque token signed in through the user info endpoint" + ); + + // ... and a directory saved before the policy keeps doing so + let directory = test_directory_with(&base, None, OpaqueTokens::Userinfo); + assert_eq!( + sign_in(&directory, "an-opaque-token".to_string()) + .await + .map(|account| account.email) + .ok() + .as_deref(), + Some("john@example.org") + ); + } + + // http-directory-services-13: introspection signs in only an active + // token this provider issued for the required audience + #[tokio::test] + async fn introspection_checks_what_the_provider_says() { + let base = provider(|request| { + let (head, body) = request.split_once("\r\n\r\n").unwrap_or_default(); + // RFC 6749 2.3.1: id and secret form-encoded, then Basic + let authorized = head.lines().any(|line| { + line.eq_ignore_ascii_case(&format!( + "authorization: Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + "inbuxa:se+cret%2F%2B" + ) + )) + }); + if !head.starts_with("POST /introspect ") || !authorized { + return (401, json!({"error": "invalid_client"}).to_string()); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut claims = json!({ + "active": true, + "iss": "https://idp.example.org", + "aud": ["inbuxa", "account"], + "exp": now + 300, + "email": "john@example.org", + }); + match body + .strip_prefix("token=") + .and_then(|body| body.split('&').next()) + .unwrap_or_default() + { + "good%2Btoken" => {} + "inactive" => claims = json!({"active": false}), + "foreign" => claims["iss"] = json!("https://other.example.org"), + "anonymous" => { + claims.as_object_mut().unwrap().remove("iss"); + } + "elsewhere" => claims["aud"] = json!("another-app"), + "unaddressed" => { + claims.as_object_mut().unwrap().remove("aud"); + } + "refresh" => claims["typ"] = json!("Refresh"), + "expired" => claims["exp"] = json!(1), + _ => claims = json!({"active": "true"}), + } + (200, claims.to_string()) + }) + .await; + let introspection = |client_secret: &str| { + OpaqueTokens::Introspection(Introspection { + url: Some(format!("{base}/introspect")), + client_id: "inbuxa".to_string(), + client_secret: client_secret.to_string(), + }) + }; + let directory = test_directory_with(&base, Some("inbuxa"), introspection("se cret/+")); + + assert_eq!( + sign_in(&directory, "good+token".to_string()) + .await + .map(|account| account.email) + .ok() + .as_deref(), + Some("john@example.org") + ); + for token in [ + "inactive", + "foreign", + "anonymous", + "elsewhere", + "unaddressed", + "refresh", + "expired", + "not-a-boolean", + ] { + let err = sign_in(&directory, token.to_string()).await.unwrap_err(); + assert!( + err.matches(trc::EventType::Auth(AuthEvent::Failed)), + "{token}: {err:?}" + ); + } + + // A refusal of the server's own credentials is not the person's fault + let directory = test_directory_with(&base, Some("inbuxa"), introspection("wrong")); + let err = sign_in(&directory, "good+token".to_string()) + .await + .unwrap_err(); + assert!( + err.matches(trc::EventType::Auth(AuthEvent::Error)), + "{err:?}" + ); + } + + /// A body over 1 MiB that is otherwise a good answer. + fn oversized(mut body: serde_json::Value) -> String { + body["padding"] = json!("x".repeat(1024 * 1024)); + body.to_string() + } + + // http-directory-services-13: what the provider sends back is read up to + // 1 MiB, never whole + #[tokio::test] + async fn provider_answers_are_capped() { + let base = provider(|request| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let body = if request.starts_with("POST /introspect ") { + json!({ + "active": true, + "iss": "https://idp.example.org", + "exp": now + 300, + "email": "john@example.org", + }) + } else if request.starts_with("GET /jwks ") { + json!({"keys": []}) + } else { + json!({"email": "john@example.org"}) + }; + (200, oversized(body)) + }) + .await; + + let directory = test_directory_with(&base, None, OpaqueTokens::Userinfo); + assert!( + sign_in(&directory, "an-opaque-token".to_string()) + .await + .is_err(), + "user info" + ); + let directory = test_directory_with( + &base, + None, + OpaqueTokens::Introspection(Introspection { + url: Some(format!("{base}/introspect")), + client_id: "inbuxa".to_string(), + client_secret: "secret".to_string(), + }), + ); + assert!( + sign_in(&directory, "an-opaque-token".to_string()) + .await + .is_err(), + "introspection" + ); + assert!( + fetch_jwks_keys(&Client::new(), &format!("{base}/jwks")) + .await + .is_err(), + "JWKS" + ); + } + + // http-directory-services-13: a token without a kid that no cached key + // verifies fetches the JWKS again, as an unknown kid does, so a provider + // that rotated its only key keeps working + #[tokio::test] + async fn tokens_without_a_kid_refresh_the_keys_once() { + const ROTATED_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIGJCOx1xrTLtywhRlvhdTe5Sm7UNQlVFXzqomt76r12T +-----END PRIVATE KEY-----"; + let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = fetches.clone(); + let base = provider(move |_| { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let key = json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "b2naX3qFwv_RDOHZMPsYSii82JicgY3srqJSZfranzE", + "use": "sig", + }); + (200, json!({"keys": [key]}).to_string()) + }) + .await; + let directory = test_directory_at(&base); + directory.cache.write().await.last_updated = Instant::now() - Duration::from_secs(600); + + let rotated = |claims: serde_json::Value| { + let mut claims = claims; + claims["iss"] = json!("https://idp.example.org"); + claims["exp"] = json!( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 300 + ); + claims["email"] = json!("john@example.org"); + jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::EdDSA), + &claims, + &jsonwebtoken::EncodingKey::from_ed_pem(ROTATED_KEY.as_bytes()).unwrap(), + ) + .unwrap() + }; + assert_eq!( + sign_in(&directory, rotated(json!({}))) + .await + .map(|account| account.email) + .ok() + .as_deref(), + Some("john@example.org") + ); + assert_eq!(fetches.load(std::sync::atomic::Ordering::Relaxed), 1); + + // A forged one right after doesn't fetch again + let mut forged = rotated(json!({})); + forged.replace_range(forged.len() - 4.., "AAAA"); + assert!(sign_in(&directory, forged).await.is_err()); + assert_eq!(fetches.load(std::sync::atomic::Ordering::Relaxed), 1); + } + #[test] fn extract_string_list_distinguishes_absent_from_empty() { assert_eq!( diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs index 0ddb9310..03338c54 100644 --- a/crates/directory/src/backend/oidc/mod.rs +++ b/crates/directory/src/backend/oidc/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use ahash::AHashMap; @@ -22,6 +24,26 @@ pub struct OidcConfig { pub claim_name: Option, pub claim_groups: Option, pub default_domain: Option, + pub opaque_tokens: OpaqueTokens, +} + +/// inbuxa: http-directory-services-13: what a bearer token that isn't a +/// JWT signs in by. +pub enum OpaqueTokens { + /// Nothing: refused. + Reject, + /// The provider's user info endpoint answering for it, which it does for + /// any token it issued, to any application. + Userinfo, + /// The provider's token introspection endpoint (RFC 7662). + Introspection(Introspection), +} + +pub struct Introspection { + /// None until discovery: then the discovery document's, if not set. + pub url: Option, + pub client_id: String, + pub client_secret: String, } pub struct OidcDiscovery { @@ -38,6 +60,9 @@ pub struct DiscoveryDocument { pub authorization_endpoint: String, #[serde(skip_serializing_if = "Option::is_none")] pub end_session_endpoint: Option, + // inbuxa: read for opaque tokens, not passed on to clients + #[serde(default, skip_serializing)] + pub introspection_endpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scopes_supported: Option>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs index be30d03c..19e2a910 100644 --- a/crates/groupware/src/calendar/dates.rs +++ b/crates/groupware/src/calendar/dates.rs @@ -15,7 +15,8 @@ use calcard::{ common::timezone::Tz, icalendar::{ ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarRecurrenceRule, - ICalendarValue, dates::TimeOrDelta, + ICalendarValue, + dates::{CalendarError, CalendarErrorType, TimeOrDelta}, }, }; use compact_str::ToCompactString; @@ -27,6 +28,21 @@ use store::{ const MAX_TIME_SPAN: i64 = u32::MAX as i64; +// inbuxa: the object to log with a rule expansion error. Not when the work +// budget ran out: anyone who can get an object stored (a sharee, or a sender +// whose invitation is processed) can cause that on purpose, and every time it +// would copy up to the whole object into the log +fn expansion_error_details(ical: &ICalendar, errors: &[CalendarError]) -> Option { + if errors + .iter() + .any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted) + { + None + } else { + Some(ical.to_string()) + } +} + // calcard builds every candidate of one period (days x BYHOUR x BYMINUTE x // BYSECOND) before the expansion limit applies, so a rule is refused when that // set is larger than this. @@ -233,6 +249,7 @@ impl CalendarEventData { } if !expanded.errors.is_empty() { + let details = expansion_error_details(&ical, &expanded.errors); trc::event!( Calendar(trc::CalendarEvent::RuleExpansionError), Reason = expanded @@ -240,7 +257,7 @@ impl CalendarEventData { .into_iter() .map(|e| e.error.to_compact_string()) .collect::>(), - Details = ical.to_string(), + Details = details, Limit = max_expansions, ); } @@ -355,3 +372,55 @@ impl ArchivedTimezone { } } } + +#[cfg(test)] +mod tests { + use calcard::{ + common::timezone::Tz, + icalendar::{ICalendar, dates::CalendarErrorType}, + }; + + // The shared expansion budget is carried by vendor/calcard, so this stops + // building if Cargo.lock moves calcard past the vendored copy + #[test] + fn rule_expansion_shares_one_budget() { + let mut ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n".to_string(); + for _ in 0..40 { + ical.push_str( + "BEGIN:VEVENT\r\nUID:budget\r\nDTSTART:20000101T000000Z\r\n\ + RRULE:FREQ=DAILY;BYMONTH=2;BYMONTHDAY=31\r\nEND:VEVENT\r\n", + ); + } + ical.push_str("END:VCALENDAR\r\n"); + + let parsed = ICalendar::parse(&ical).unwrap(); + let expanded = parsed.expand_dates(Tz::UTC, 3000); + assert!(expanded.events.is_empty()); + assert!( + expanded + .errors + .iter() + .any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted) + ); + + // Anyone who can get an object stored can use up the budget, so its + // log entry leaves the object out + assert_eq!( + super::expansion_error_details(&parsed, &expanded.errors), + None + ); + } + + #[test] + fn other_expansion_errors_keep_the_object() { + let ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:x\r\n\ + RRULE:FREQ=DAILY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let parsed = ICalendar::parse(ical).unwrap(); + let expanded = parsed.expand_dates(Tz::UTC, 3000); + assert!(!expanded.errors.is_empty()); + assert!( + super::expansion_error_details(&parsed, &expanded.errors) + .is_some_and(|details| details.contains("UID:x")) + ); + } +} diff --git a/crates/imap-proto/src/protocol/capability.rs b/crates/imap-proto/src/protocol/capability.rs index 4fb7c577..f22dce7b 100644 --- a/crates/imap-proto/src/protocol/capability.rs +++ b/crates/imap-proto/src/protocol/capability.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ImapResponse, authenticate::Mechanism}; @@ -144,6 +146,7 @@ impl Capability { is_authenticated: bool, offer_tls: bool, allow_auth: bool, + allow_bearer: bool, message_limit: u32, save_limit: u32, ) -> Vec { @@ -195,11 +198,14 @@ impl Capability { Capability::SaveLimit(save_limit), ]); } else if allow_auth { - capabilities.extend([ - Capability::Auth(Mechanism::Plain), - Capability::Auth(Mechanism::OAuthBearer), - Capability::Auth(Mechanism::XOauth2), - ]); + capabilities.push(Capability::Auth(Mechanism::Plain)); + // inbuxa: bearer tokens are only offered over TLS + if allow_bearer { + capabilities.extend([ + Capability::Auth(Mechanism::OAuthBearer), + Capability::Auth(Mechanism::XOauth2), + ]); + } } else { capabilities.push(Capability::LoginDisabled); } diff --git a/crates/imap/src/core/client.rs b/crates/imap/src/core/client.rs index b0c105fe..435d4d61 100644 --- a/crates/imap/src/core/client.rs +++ b/crates/imap/src/core/client.rs @@ -18,7 +18,7 @@ use imap_proto::{ }; use trc::SecurityEvent; -use super::{SelectedMailbox, Session, SessionData, State}; +use super::{SelectedMailbox, Session, SessionData, State, gate::OpKind}; impl Session { pub async fn ingest(&mut self, bytes: &[u8]) -> SessionResult { @@ -113,6 +113,14 @@ impl Session { .collect::>(), _ => Vec::new(), }; + // inbuxa: a command handled here rather than in a spawned task + // waits for every operation in flight and holds the whole gate + // while it runs; the others take their permit in their handler + let inline_permit = if takes_own_permit(&request.command) { + None + } else { + Some(self.op_gate.acquire(OpKind::Write).await) + }; let dispatch = async { match request.command { Command::List | Command::Lsub => self @@ -285,6 +293,8 @@ impl Session { store::backend::scaleout::replica::replica_read(replica_accounts, dispatch).await }; + drop(inline_permit); + match result { Ok(SessionResult::Continue) => (), Ok(result) => return result, @@ -309,6 +319,36 @@ impl Session { } } +// inbuxa: commands whose handler takes a read or write permit itself and +// runs the operation in a spawned task +fn takes_own_permit(command: &Command) -> bool { + matches!( + command, + Command::List + | Command::Lsub + | Command::Create + | Command::Delete + | Command::Rename + | Command::Status + | Command::Append + | Command::Search(_) + | Command::Sort(_) + | Command::Fetch(_) + | Command::Store(_) + | Command::Copy(_) + | Command::Move(_) + | Command::Thread(_) + | Command::Subscribe + | Command::Unsubscribe + | Command::SetAcl + | Command::DeleteAcl + | Command::GetAcl + | Command::MyRights + | Command::GetQuota + | Command::GetQuotaRoot + ) +} + pub fn group_requests( requests: &mut Peekable>>, mut grouped_requests: Vec>, diff --git a/crates/imap/src/core/gate.rs b/crates/imap/src/core/gate.rs new file mode 100644 index 00000000..6a36e906 --- /dev/null +++ b/crates/imap/src/core/gate.rs @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: bounds how many operations one IMAP session runs at once and +//! keeps commands that change state in order (RFC 9051, section 5.5). A +//! read takes one permit and a write takes all of them, so a write starts +//! once every earlier operation has finished and later ones wait for it. +//! The session loop waits for its permit before it starts the operation +//! and reads no more commands meanwhile. + +use std::sync::Arc; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +/// Operations one session may run at the same time. +pub const MAX_CONCURRENT_OPS: u32 = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpKind { + /// Leaves the account and the session as they were + Read, + /// Changes the account or the session, so runs on its own + Write, +} + +pub type OpPermit = OwnedSemaphorePermit; + +#[derive(Clone)] +pub struct OpGate(Arc); + +impl OpGate { + pub fn new() -> Self { + OpGate(Arc::new(Semaphore::new(MAX_CONCURRENT_OPS as usize))) + } + + /// Waits until an operation of this kind may start. Permits are handed + /// out in the order they were asked for. + pub async fn acquire(&self, kind: OpKind) -> OpPermit { + let permits = match kind { + OpKind::Read => 1, + OpKind::Write => MAX_CONCURRENT_OPS, + }; + self.0 + .clone() + .acquire_many_owned(permits) + .await + .expect("the operation gate is never closed") + } +} + +impl Default for OpGate { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::{MAX_CONCURRENT_OPS, OpGate, OpKind}; + use parking_lot::Mutex; + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, + }; + + // A permit that can't be had now: nothing else here frees one, so the + // timeout always expires + async fn is_blocked(gate: &OpGate, kind: OpKind) -> bool { + tokio::time::timeout(Duration::from_millis(50), gate.acquire(kind)) + .await + .is_err() + } + + #[tokio::test] + async fn reads_are_bounded() { + let gate = OpGate::new(); + let mut held = Vec::new(); + for _ in 0..MAX_CONCURRENT_OPS { + held.push(gate.acquire(OpKind::Read).await); + } + assert!(is_blocked(&gate, OpKind::Read).await); + held.pop(); + held.push(gate.acquire(OpKind::Read).await); + } + + #[tokio::test] + async fn write_waits_for_reads_and_reads_wait_for_write() { + let gate = OpGate::new(); + let read = gate.acquire(OpKind::Read).await; + assert!(is_blocked(&gate, OpKind::Write).await); + drop(read); + + let write = gate.acquire(OpKind::Write).await; + assert!(is_blocked(&gate, OpKind::Read).await); + assert!(is_blocked(&gate, OpKind::Write).await); + drop(write); + gate.acquire(OpKind::Read).await; + } + + // Drives the gate the way the session loop does: permits are taken in + // command order, then each operation runs in its own task + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn pipelined_operations_keep_order() { + use OpKind::{Read, Write}; + let commands = [ + Read, Read, Read, Read, Read, Read, Write, Read, Read, Write, Write, Read, Read, Read, + Read, Read, Read, Read, Write, Read, + ]; + let gate = OpGate::new(); + let running = Arc::new(AtomicUsize::new(0)); + let most_running = Arc::new(AtomicUsize::new(0)); + let log = Arc::new(Mutex::new(Vec::new())); + let mut tasks = Vec::new(); + + for (id, kind) in commands.into_iter().enumerate() { + let permit = gate.acquire(kind).await; + let running = running.clone(); + let most_running = most_running.clone(); + let log = log.clone(); + tasks.push(tokio::spawn(async move { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + most_running.fetch_max(now, Ordering::SeqCst); + log.lock().push((id, true)); + tokio::time::sleep(Duration::from_millis(5)).await; + log.lock().push((id, false)); + running.fetch_sub(1, Ordering::SeqCst); + drop(permit); + })); + } + for task in tasks { + task.await.unwrap(); + } + + assert!(most_running.load(Ordering::SeqCst) <= MAX_CONCURRENT_OPS as usize); + let log = log.lock(); + let position = |id: usize, start: bool| log.iter().position(|e| *e == (id, start)).unwrap(); + for (write, kind) in commands.iter().enumerate() { + if *kind != Write { + continue; + } + for other in 0..commands.len() { + if other < write { + assert!( + position(other, false) < position(write, true), + "write {write} started before {other} finished" + ); + } else if other > write { + assert!( + position(write, false) < position(other, true), + "{other} started before write {write} finished" + ); + } + } + } + } +} diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs index 7c41c02d..f2edcf73 100644 --- a/crates/imap/src/core/mod.rs +++ b/crates/imap/src/core/mod.rs @@ -29,6 +29,7 @@ use tokio::{ use trc::AddContext; pub mod client; +pub mod gate; pub mod mailbox; pub mod message; pub mod session; @@ -61,6 +62,8 @@ pub struct Session { pub in_flight: InFlight, pub remote_addr: IpAddr, pub session_id: u64, + // inbuxa: bounds and orders the operations this session runs + pub op_gate: gate::OpGate, } pub struct SessionData { diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs index e3ed12ae..729e9295 100644 --- a/crates/imap/src/core/session.rs +++ b/crates/imap/src/core/session.rs @@ -8,7 +8,7 @@ use super::{ImapSessionManager, Session, State}; use crate::{ - GREETING_WITH_TLS, GREETING_WITH_TLS_LOGIN_DISABLED, GREETING_WITHOUT_TLS, + GREETING_CLEAR_TEXT, GREETING_WITH_TLS, GREETING_WITH_TLS_LOGIN_DISABLED, GREETING_WITHOUT_TLS, GREETING_WITHOUT_TLS_LOGIN_DISABLED, }; use common::{ @@ -129,7 +129,8 @@ impl Session { let greeting = match (offer_tls, allow_auth) { (true, true) => &GREETING_WITH_TLS, (true, false) => &GREETING_WITH_TLS_LOGIN_DISABLED, - (false, true) => &GREETING_WITHOUT_TLS, + (false, true) if is_tls => &GREETING_WITHOUT_TLS, + (false, true) => &GREETING_CLEAR_TEXT, (false, false) => &GREETING_WITHOUT_TLS_LOGIN_DISABLED, }; @@ -169,6 +170,7 @@ impl Session { session_id: session.session_id, in_flight: session.in_flight, remote_addr: session.remote_ip, + op_gate: super::gate::OpGate::new(), stream_rx, stream_tx: Arc::new(tokio::sync::Mutex::new(stream_tx)), }) @@ -236,6 +238,7 @@ impl Session { session_id: self.session_id, in_flight: self.in_flight, remote_addr: self.remote_addr, + op_gate: self.op_gate, stream_rx, stream_tx, }) diff --git a/crates/imap/src/lib.rs b/crates/imap/src/lib.rs index 010e5697..4d6aa835 100644 --- a/crates/imap/src/lib.rs +++ b/crates/imap/src/lib.rs @@ -21,21 +21,32 @@ pub mod op; static SERVER_GREETING: &str = concat!(types::brand!(), " IMAP4rev2 at your service."); pub(crate) static GREETING_WITH_TLS: LazyLock> = - LazyLock::new(|| build_greeting(true, true)); + LazyLock::new(|| build_greeting(true, true, false)); pub(crate) static GREETING_WITH_TLS_LOGIN_DISABLED: LazyLock> = - LazyLock::new(|| build_greeting(true, false)); + LazyLock::new(|| build_greeting(true, false, false)); pub(crate) static GREETING_WITHOUT_TLS: LazyLock> = - LazyLock::new(|| build_greeting(false, true)); + LazyLock::new(|| build_greeting(false, true, true)); + +// inbuxa: clear text with no STARTTLS to offer, so no bearer tokens either +pub(crate) static GREETING_CLEAR_TEXT: LazyLock> = + LazyLock::new(|| build_greeting(false, true, false)); pub(crate) static GREETING_WITHOUT_TLS_LOGIN_DISABLED: LazyLock> = - LazyLock::new(|| build_greeting(false, false)); + LazyLock::new(|| build_greeting(false, false, false)); -fn build_greeting(offer_tls: bool, allow_auth: bool) -> Vec { +fn build_greeting(offer_tls: bool, allow_auth: bool, allow_bearer: bool) -> Vec { StatusResponse::ok(SERVER_GREETING) .with_code(ResponseCode::Capability { - capabilities: Capability::all_capabilities(false, offer_tls, allow_auth, 0, 0), + capabilities: Capability::all_capabilities( + false, + offer_tls, + allow_auth, + allow_bearer, + 0, + 0, + ), }) .into_bytes() } diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index 800a2f09..05e7cca4 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -7,7 +7,7 @@ */ use crate::{ - core::{MailboxId, Session, SessionData, State}, + core::{MailboxId, Session, SessionData, State, gate::OpKind}, op::ImapContext, spawn_op, }; @@ -40,13 +40,14 @@ impl Session { pub async fn handle_get_acl(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapAclGet)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let op_start = Instant::now(); let arguments = request.parse_acl(self.is_utf8)?; let is_utf8 = self.version.is_rev2() || self.is_utf8; let data = self.state.session_data(); - spawn_op!(data, { + spawn_op!(data, permit, { let (mailbox_id, mailbox_, _) = data .get_acl_mailbox(&arguments, true) .await @@ -169,13 +170,14 @@ impl Session { pub async fn handle_my_rights(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapMyRights)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let op_start = Instant::now(); let arguments = request.parse_acl(self.is_utf8)?; let data = self.state.session_data(); let is_utf8 = self.version.is_rev2() || self.is_utf8; - spawn_op!(data, { + spawn_op!(data, permit, { let (mailbox_id, mailbox_, access_token) = data .get_acl_mailbox(&arguments, false) .await @@ -262,13 +264,14 @@ impl Session { pub async fn handle_set_acl(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapAclSet)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let command = request.command; let arguments = request.parse_acl(self.is_utf8)?; let data = self.state.session_data(); - spawn_op!(data, { + spawn_op!(data, permit, { // Validate mailbox let (mailbox_id, current_mailbox, access_token) = data .get_acl_mailbox(&arguments, true) diff --git a/crates/imap/src/op/append.rs b/crates/imap/src/op/append.rs index f85645c3..a6cdf732 100644 --- a/crates/imap/src/op/append.rs +++ b/crates/imap/src/op/append.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ImapContext, ToModSeq}; use crate::{ - core::{ImapUidToId, MailboxId, SelectedMailbox, Session, SessionData}, + core::{ImapUidToId, MailboxId, SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use common::{auth::BuildAccessToken, ipc::PushNotification, network::SessionStream}; @@ -29,6 +31,7 @@ impl Session { pub async fn handle_append(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapAppend)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let arguments = request.parse_append(self.is_utf8)?; @@ -67,7 +70,7 @@ impl Session { }; let is_qresync = self.is_qresync; - spawn_op!(data, { + spawn_op!(data, permit, { let response = data .append_messages(arguments, selected_mailbox, mailbox, is_qresync, op_start) .await? diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index b540b266..f02c2a45 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -25,6 +25,16 @@ impl Session { pub async fn handle_authenticate(&mut self, request: Request) -> trc::Result<()> { let mut args = request.parse_authenticate()?; + // inbuxa: a bearer token is a credential on its own, so it is never + // asked for or taken on clear text, whatever the plain-text setting + if !self.is_tls && matches!(args.mechanism, Mechanism::OAuthBearer | Mechanism::XOauth2) { + return Err(trc::AuthEvent::Error + .into_err() + .details("Bearer tokens require TLS.") + .id(args.tag) + .code(ResponseCode::PrivacyRequired)); + } + match args.mechanism { Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => { if !args.params.is_empty() { @@ -138,6 +148,7 @@ impl Session { true, !self.is_tls && self.instance.acceptor.is_tls(), true, + self.is_tls, self.server.core.imap.max_messages_per_command, self.server.core.imap.max_messages_per_save, ), diff --git a/crates/imap/src/op/capability.rs b/crates/imap/src/op/capability.rs index f8a45707..2069698d 100644 --- a/crates/imap/src/op/capability.rs +++ b/crates/imap/src/op/capability.rs @@ -44,6 +44,7 @@ impl Session { self.state.is_authenticated(), !self.is_tls && self.instance.acceptor.is_tls(), self.is_tls || self.server.core.imap.allow_plain_auth, + self.is_tls, self.server.core.imap.max_messages_per_command, self.server.core.imap.max_messages_per_save, ), diff --git a/crates/imap/src/op/copy_move.rs b/crates/imap/src/op/copy_move.rs index f0eeabee..85cf92dc 100644 --- a/crates/imap/src/op/copy_move.rs +++ b/crates/imap/src/op/copy_move.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ImapContext; use crate::{ - core::{MailboxId, SelectedMailbox, Session, SessionData}, + core::{MailboxId, SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use ahash::AHashMap; @@ -58,6 +60,7 @@ impl Session { } else { Permission::ImapCopy })?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let arguments = request.parse_copy_move(self.is_utf8)?; @@ -70,7 +73,7 @@ impl Session { self.server.core.imap.max_messages_per_save }; - spawn_op!(data, { + spawn_op!(data, permit, { // Refresh mailboxes data.synchronize_mailboxes(false) .await diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 8858168a..8f1d1f14 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -7,7 +7,7 @@ */ use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, op::ImapContext, spawn_op, }; @@ -28,12 +28,13 @@ impl Session { pub async fn handle_create(&mut self, requests: Vec>) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapCreate)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let data = self.state.session_data(); let is_utf8 = self.is_utf8; let is_objectid = self.is_objectid; - spawn_op!(data, { + spawn_op!(data, permit, { for request in requests { match request.parse_create(is_utf8) { Ok(argument) => match data.create_folder(argument, is_objectid).await { diff --git a/crates/imap/src/op/delete.rs b/crates/imap/src/op/delete.rs index 60eb3567..3ec6230d 100644 --- a/crates/imap/src/op/delete.rs +++ b/crates/imap/src/op/delete.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ImapContext; use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, spawn_op, }; use common::network::SessionStream; @@ -21,11 +23,12 @@ impl Session { pub async fn handle_delete(&mut self, requests: Vec>) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapDelete)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let data = self.state.session_data(); let is_utf8 = self.is_utf8; - spawn_op!(data, { + spawn_op!(data, permit, { for request in requests { match request.parse_delete(is_utf8) { Ok(argument) => match data.delete_folder(argument).await { diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs index a34f3769..64475a58 100644 --- a/crates/imap/src/op/fetch.rs +++ b/crates/imap/src/op/fetch.rs @@ -8,7 +8,7 @@ use super::{FromModSeq, ImapContext}; use crate::{ - core::{SelectedMailbox, Session, SessionData}, + core::{SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use ahash::AHashMap; @@ -94,11 +94,23 @@ impl Session { } } + // inbuxa: a FETCH that marks messages \Seen changes the mailbox + let kind = if mailbox.is_select + && ops.iter().any(|op| { + op.as_ref() + .is_ok_and(|(_, _, arguments)| may_set_seen(&arguments.attributes)) + }) { + OpKind::Write + } else { + OpKind::Read + }; + let permit = self.op_gate.acquire(kind).await; + if activate_objectid && let Some(enabled) = self.activate_objectid() { self.write_bytes(enabled).await?; } - spawn_op!(data, { + spawn_op!(data, permit, { for op in ops { match op { Ok((is_uid, enabled_condstore, arguments)) => { @@ -131,6 +143,19 @@ impl Session { } } +// inbuxa: errs on the side of a write; a header-only section counts too +fn may_set_seen(attributes: &[Attribute]) -> bool { + attributes.iter().any(|attribute| { + matches!( + attribute, + Attribute::BodySection { peek: false, .. } + | Attribute::Binary { peek: false, .. } + | Attribute::Rfc822 + | Attribute::Rfc822Text + ) + }) +} + impl SessionData { #[allow(clippy::too_many_arguments)] pub async fn fetch( diff --git a/crates/imap/src/op/list.rs b/crates/imap/src/op/list.rs index 4c2274b8..8507f2ad 100644 --- a/crates/imap/src/op/list.rs +++ b/crates/imap/src/op/list.rs @@ -9,7 +9,7 @@ use std::time::Instant; use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, spawn_op, }; use common::network::SessionStream; @@ -48,6 +48,7 @@ impl Session { request.parse_lsub(self.is_utf8) }?; + let permit = self.op_gate.acquire(OpKind::Read).await; // Activate OBJECTID+ when the OBJECTID return option or STATUS attribute is requested if let Arguments::Extended { return_options, .. } = &arguments && return_options.iter().any(|option| match option { @@ -67,6 +68,7 @@ impl Session { spawn_op!( data, + permit, data.list(arguments, is_lsub, version, is_utf8, op_start) .await ) diff --git a/crates/imap/src/op/mod.rs b/crates/imap/src/op/mod.rs index 99baf493..92b39325 100644 --- a/crates/imap/src/op/mod.rs +++ b/crates/imap/src/op/mod.rs @@ -62,10 +62,14 @@ impl ToModSeq for u64 { #[macro_export] macro_rules! spawn_op { - ($data:expr, $($code:tt)*) => { + ($data:expr, $permit:expr, $($code:tt)*) => { { + // inbuxa: the operation holds the permit the handler took from the + // session's gate until it finishes + let permit: $crate::core::gate::OpPermit = $permit; // inbuxa: ST-6: the operation keeps its read scope tokio::spawn(store::backend::scaleout::replica::carry(async move { + let _permit = permit; let data = &($data); if let Err(err) = (async { diff --git a/crates/imap/src/op/quota.rs b/crates/imap/src/op/quota.rs index 34ea9628..eca5c854 100644 --- a/crates/imap/src/op/quota.rs +++ b/crates/imap/src/op/quota.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ /* @@ -11,7 +13,7 @@ */ use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, op::ImapContext, spawn_op, }; @@ -32,10 +34,11 @@ impl Session { pub async fn handle_get_quota(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapStatus)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let data = self.state.session_data(); - spawn_op!(data, { + spawn_op!(data, permit, { match request.parse_get_quota() { Ok(argument) => match data.get_quota(argument).await { Ok(response) => { @@ -55,11 +58,12 @@ impl Session { pub async fn handle_get_quota_root(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapStatus)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let data = self.state.session_data(); let is_utf8 = self.is_utf8; - spawn_op!(data, { + spawn_op!(data, permit, { match request.parse_get_quota_root(is_utf8) { Ok(argument) => match data.get_quota_root(argument).await { Ok(response) => { diff --git a/crates/imap/src/op/rename.rs b/crates/imap/src/op/rename.rs index 704f1000..b602f93b 100644 --- a/crates/imap/src/op/rename.rs +++ b/crates/imap/src/op/rename.rs @@ -2,10 +2,12 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, spawn_op, }; use common::{network::SessionStream, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder}; @@ -30,13 +32,14 @@ impl Session { pub async fn handle_rename(&mut self, request: Request) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapRename)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let arguments = request.parse_rename(self.is_utf8)?; let data = self.state.session_data(); let is_objectid = self.is_objectid; - spawn_op!(data, { + spawn_op!(data, permit, { let response = data.rename_folder(arguments, is_objectid, op_start).await?; data.write_bytes(response.into_bytes()).await }) diff --git a/crates/imap/src/op/search.rs b/crates/imap/src/op/search.rs index aa09b87e..1e58b290 100644 --- a/crates/imap/src/op/search.rs +++ b/crates/imap/src/op/search.rs @@ -8,7 +8,7 @@ use super::{FromModSeq, ToModSeq}; use crate::{ - core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData}, + core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use common::network::SessionStream; @@ -58,6 +58,7 @@ impl Session { request.parse_sort() }?; + let permit = self.op_gate.acquire(OpKind::Read).await; // RFC 9586 forbids the sequence set criterion once UIDONLY is enabled if self.is_uidonly && arguments.filter.iter().any(|filter| { @@ -86,7 +87,7 @@ impl Session { (None, None) }; - spawn_op!(data, { + spawn_op!(data, permit, { let tag = std::mem::take(&mut arguments.tag); let bytes = match data .search( diff --git a/crates/imap/src/op/status.rs b/crates/imap/src/op/status.rs index a1a699fb..8a93462b 100644 --- a/crates/imap/src/op/status.rs +++ b/crates/imap/src/op/status.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ToModSeq; use crate::{ - core::{Mailbox, Session, SessionData}, + core::{Mailbox, Session, SessionData, gate::OpKind}, op::ImapContext, spawn_op, }; @@ -30,6 +32,7 @@ impl Session { pub async fn handle_status(&mut self, requests: Vec>) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapStatus)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let is_utf8 = self.is_utf8; @@ -53,7 +56,7 @@ impl Session { let data = self.state.session_data(); - spawn_op!(data, { + spawn_op!(data, permit, { let mut did_sync = false; for request in parsed { diff --git a/crates/imap/src/op/store.rs b/crates/imap/src/op/store.rs index 56078301..5f432d86 100644 --- a/crates/imap/src/op/store.rs +++ b/crates/imap/src/op/store.rs @@ -8,7 +8,7 @@ use super::{FromModSeq, ImapContext}; use crate::{ - core::{SelectedMailbox, Session, SessionData}, + core::{SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use ahash::AHashSet; @@ -51,6 +51,7 @@ impl Session { ) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapStore)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let arguments = request.parse_store()?; @@ -61,7 +62,7 @@ impl Session { let message_limit = self.server.core.imap.max_messages_per_command; if spawn { - spawn_op!(data, { + spawn_op!(data, permit, { let response = data .store( arguments, diff --git a/crates/imap/src/op/subscribe.rs b/crates/imap/src/op/subscribe.rs index 22e1df61..19ad7ca8 100644 --- a/crates/imap/src/op/subscribe.rs +++ b/crates/imap/src/op/subscribe.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ImapContext; use crate::{ - core::{Session, SessionData}, + core::{Session, SessionData, gate::OpKind}, spawn_op, }; use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; @@ -27,12 +29,13 @@ impl Session { ) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapSubscribe)?; + let permit = self.op_gate.acquire(OpKind::Write).await; let op_start = Instant::now(); let arguments = request.parse_subscribe(self.is_utf8)?; let data = self.state.session_data(); - spawn_op!(data, { + spawn_op!(data, permit, { let response = data .subscribe_folder( arguments.tag, diff --git a/crates/imap/src/op/thread.rs b/crates/imap/src/op/thread.rs index 94dc836d..c68a7730 100644 --- a/crates/imap/src/op/thread.rs +++ b/crates/imap/src/op/thread.rs @@ -2,10 +2,12 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ - core::{SelectedMailbox, Session, SessionData}, + core::{SelectedMailbox, Session, SessionData, gate::OpKind}, spawn_op, }; use ahash::AHashMap; @@ -31,13 +33,14 @@ impl Session { ) -> trc::Result<()> { // Validate access self.assert_has_permission(Permission::ImapThread)?; + let permit = self.op_gate.acquire(OpKind::Read).await; let op_start = Instant::now(); let command = request.command; let mut arguments = request.parse_thread()?; let (data, mailbox) = self.state.mailbox_state(); - spawn_op!(data, { + spawn_op!(data, permit, { let tag = std::mem::take(&mut arguments.tag); match data.thread(arguments, mailbox, is_uid, op_start).await { diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 0eb21160..3e6ff895 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -30,6 +30,7 @@ pub mod log_settings; pub mod mail_rule; pub mod masked_email; pub mod masked_secrets; // inbuxa: "****" is never saved as a secret +pub mod oidc_directory; // inbuxa: http-directory-services-13 pub mod protocol_policy; pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod security_acceptance; diff --git a/crates/jmap/src/inbuxa/oidc_directory.rs b/crates/jmap/src/inbuxa/oidc_directory.rs new file mode 100644 index 00000000..8ff8375c --- /dev/null +++ b/crates/jmap/src/inbuxa/oidc_directory.rs @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What an external OpenID Connect directory must name before it is saved +//! (http-directory-services-13). +//! +//! A directory with no required audience accepts any token its provider +//! issued, to any application, so a new one must name the audience: one +//! made by `x:Directory/set`, made by changing another directory's type, or +//! made at setup. A directory saved before this rule keeps working without +//! one and logs a warning each time it loads; it can still be edited, but an +//! audience it has can't be removed. Requiring it of every directory is left +//! to a later release. +//! +//! A directory that checks opaque tokens by introspection names the client +//! ID and secret it calls the provider with. + +use directory::backend::oidc::config::opaque_token_policy; +use jmap_proto::error::set::{SetError, SetErrorType}; +use registry::{ + schema::{ + enums::OidcOpaqueTokens, + prelude::Property, + structs::{Directory, SecretKeyOptional}, + }, + types::error::ValidationError, +}; + +/// `stored` is the directory as saved before this write, None for a new one. +pub fn validate( + directory: &Directory, + stored: Option<&Directory>, +) -> Result<(), SetError> { + let Directory::Oidc(directory) = directory else { + return Ok(()); + }; + let may_omit_audience = matches!( + stored, + Some(Directory::Oidc(stored)) if stored.require_audience.is_none() + ); + if directory.require_audience.is_none() && !may_omit_audience { + return Err(SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(vec![ValidationError::required(Property::RequireAudience)]) + .with_description( + "Set the required audience: the client ID or resource identifier registered \ + for this server at the provider. Without it, tokens the provider issued to any \ + other application would sign in.", + )); + } + + if opaque_token_policy(directory) == OidcOpaqueTokens::Introspection { + let mut missing = Vec::new(); + if directory.introspection_client_id.is_none() { + missing.push(ValidationError::required(Property::IntrospectionClientId)); + } + if matches!( + directory.introspection_client_secret, + SecretKeyOptional::None + ) { + missing.push(ValidationError::required( + Property::IntrospectionClientSecret, + )); + } + if !missing.is_empty() { + return Err(SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(missing) + .with_description( + "Opaque tokens are checked at the introspection endpoint, which needs the \ + client ID and secret this server signs in to the provider with.", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry::schema::structs::{LdapDirectory, OidcDirectory}; + + fn oidc(audience: Option<&str>) -> Directory { + Directory::Oidc(OidcDirectory { + require_audience: audience.map(str::to_string), + ..Default::default() + }) + } + + #[test] + fn new_directories_name_their_audience() { + let ldap = Directory::Ldap(LdapDirectory::default()); + + // New, or made from a directory of another type + assert!(validate(&oidc(None), None).is_err()); + assert!(validate(&oidc(None), Some(&ldap)).is_err()); + assert!(validate(&oidc(Some("inbuxa")), None).is_ok()); + assert!(validate(&ldap, None).is_ok()); + + // Saved before the audience was required: still editable without one + assert!(validate(&oidc(None), Some(&oidc(None))).is_ok()); + // ... but an audience, once set, stays + assert!(validate(&oidc(None), Some(&oidc(Some("inbuxa")))).is_err()); + } + + #[test] + fn introspection_names_its_client() { + let with = |policy: Option, url: bool, client: bool| { + Directory::Oidc(OidcDirectory { + require_audience: Some("inbuxa".to_string()), + opaque_tokens: policy, + introspection_url: url.then(|| "https://idp.example.org/introspect".to_string()), + introspection_client_id: client.then(|| "inbuxa".to_string()), + introspection_client_secret: if client { + SecretKeyOptional::Value(registry::schema::structs::SecretKeyValue { + secret: "secret".to_string(), + }) + } else { + SecretKeyOptional::None + }, + ..Default::default() + }) + }; + + assert!(validate(&with(None, false, false), None).is_ok()); + assert!(validate(&with(None, true, false), None).is_err()); + assert!(validate(&with(None, true, true), None).is_ok()); + assert!( + validate( + &with(Some(OidcOpaqueTokens::Introspection), false, false), + None + ) + .is_err() + ); + assert!( + validate( + &with(Some(OidcOpaqueTokens::Introspection), false, true), + None + ) + .is_ok() + ); + assert!(validate(&with(Some(OidcOpaqueTokens::Reject), true, false), None).is_ok()); + assert!(validate(&with(Some(OidcOpaqueTokens::Userinfo), false, false), None).is_ok()); + } +} diff --git a/crates/jmap/src/registry/mapping/bootstrap.rs b/crates/jmap/src/registry/mapping/bootstrap.rs index c47c7e0a..452e4ada 100644 --- a/crates/jmap/src/registry/mapping/bootstrap.rs +++ b/crates/jmap/src/registry/mapping/bootstrap.rs @@ -125,6 +125,15 @@ pub(crate) async fn bootstrap_set( break; } + // inbuxa: http-directory-services-13: a directory made at setup is a + // new one, and names its audience + if let Some(directory) = map_directory(&bootstrap.directory) + && let Err(err) = crate::inbuxa::oidc_directory::validate(&directory, None) + { + set.response.not_updated.append(id, err); + break; + } + // Validate domain name and hostname let server_hostname = bootstrap .server_hostname diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 5fbd3d5c..859cacf7 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -564,13 +564,30 @@ impl RegistrySet for Server { Ok(ObjectResponse::default()) } } - ObjectInner::Directory(_) if is_create => { - validate_tenant_quota( - self, - access_token, - TenantStorageQuota::MaxDirectories, - ) - .await? + ObjectInner::Directory(directory) => { + // inbuxa: http-directory-services-13: a new OIDC + // directory names its audience + let stored = match &modification { + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Directory(stored) => Some(stored), + _ => None, + }, + Modification::Create { .. } => None, + }; + if let Err(err) = + crate::inbuxa::oidc_directory::validate(directory, stored) + { + Err(err) + } else if is_create { + validate_tenant_quota( + self, + access_token, + TenantStorageQuota::MaxDirectories, + ) + .await? + } else { + Ok(ObjectResponse::default()) + } } // inbuxa: legacy-protocols LP-4 ObjectInner::NetworkListener(listener) => { diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 259778c1..a624b179 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::core::{Command, Session, State, StatusResponse}; +use crate::core::{Command, ResponseCode, Session, State, StatusResponse}; use common::{ auth::AuthRequest, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult}, @@ -30,6 +30,16 @@ impl Session { let mut tokens = request.tokens.into_iter(); let mechanism = Mechanism::parse(&tokens.next().unwrap().unwrap_bytes()) .map_err(|err| trc::AuthEvent::Error.into_err().details(err))?; + + // inbuxa: a bearer token is never asked for or taken on clear text, + // whatever the plain-text setting + if !self.stream.is_tls() && matches!(mechanism, Mechanism::OAuthBearer | Mechanism::XOauth2) + { + return Err(trc::AuthEvent::Error + .into_err() + .code(ResponseCode::EncryptNeeded) + .details("Bearer tokens require TLS.")); + } let mut params: Vec = tokens .filter_map(|token| token.unwrap_string().ok()) .collect(); diff --git a/crates/managesieve/src/op/capability.rs b/crates/managesieve/src/op/capability.rs index fc4c2f63..8b24dfb2 100644 --- a/crates/managesieve/src/op/capability.rs +++ b/crates/managesieve/src/op/capability.rs @@ -28,10 +28,14 @@ impl Session { if !self.stream.is_tls() { response.extend_from_slice(b"\"STARTTLS\"\r\n"); } - if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + // inbuxa: bearer tokens are only offered over TLS, and nothing is + // offered on clear text with plain-text sign-in off (STARTTLS is) + if self.stream.is_tls() { response.extend_from_slice(b"\"SASL\" \"PLAIN OAUTHBEARER XOAUTH2\"\r\n"); + } else if self.server.core.imap.allow_plain_auth { + response.extend_from_slice(b"\"SASL\" \"PLAIN\"\r\n"); } else { - response.extend_from_slice(b"\"SASL\" \"OAUTHBEARER XOAUTH2\"\r\n"); + response.extend_from_slice(b"\"SASL\" \"\"\r\n"); }; if let Some(sieve) = self.server diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs index 4f5bef50..72c20603 100644 --- a/crates/pop3/src/client.rs +++ b/crates/pop3/src/client.rs @@ -213,7 +213,21 @@ impl Session { | Command::Apop { .. } => { if let State::NotAuthenticated { username, .. } = &self.state { if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { - if !matches!(command, Command::Pass { .. }) || username.is_some() { + if !self.stream.is_tls() + && matches!( + command, + Command::Auth { + mechanism: Mechanism::OAuthBearer | Mechanism::XOauth2, + .. + } + ) + { + // inbuxa: a bearer token is never taken on clear + // text, whatever the plain-text setting + Err(trc::Pop3Event::Error + .into_err() + .details("Bearer tokens require TLS.")) + } else if !matches!(command, Command::Pass { .. }) || username.is_some() { Ok(command) } else { Err(trc::Pop3Event::Error diff --git a/crates/pop3/src/op/mod.rs b/crates/pop3/src/op/mod.rs index 59d35576..8136252e 100644 --- a/crates/pop3/src/op/mod.rs +++ b/crates/pop3/src/op/mod.rs @@ -20,8 +20,11 @@ pub mod list; impl Session { pub async fn handle_capa(&mut self) -> trc::Result<()> { - let mechanisms = if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + let mechanisms = if self.stream.is_tls() { vec![Mechanism::Plain, Mechanism::OAuthBearer, Mechanism::XOauth2] + } else if self.server.core.imap.allow_plain_auth { + // inbuxa: bearer tokens are only offered over TLS + vec![Mechanism::Plain] } else { // inbuxa: no mechanism is offered where none would be accepted vec![] diff --git a/crates/registry/src/inbuxa_tests.rs b/crates/registry/src/inbuxa_tests.rs new file mode 100644 index 00000000..8ea4807a --- /dev/null +++ b/crates/registry/src/inbuxa_tests.rs @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Stored formats inbuxa changed. + +use crate::{ + pickle::{Pickle, PickledStream}, + schema::{ + enums::OidcOpaqueTokens, + prelude::{ObjectImpl, ObjectInner, ObjectType}, + structs::{Bootstrap, Directory, DirectoryBootstrap, OidcDirectory}, + }, +}; + +/// An OIDC directory as it was stored before the opaque-token fields. +fn pickle_without_opaque_tokens(directory: &OidcDirectory, out: &mut Vec) { + directory.description.pickle(out); + directory.issuer_url.pickle(out); + directory.require_audience.pickle(out); + directory.require_scopes.pickle(out); + directory.claim_username.pickle(out); + directory.username_domain.pickle(out); + directory.claim_name.pickle(out); + directory.claim_groups.pickle(out); + directory.member_tenant_id.pickle(out); +} + +fn read(object: ObjectType, bytes: &[u8]) -> ObjectInner { + ObjectInner::unpickle(object, &mut PickledStream::new(bytes).unwrap()).unwrap() +} + +// http-directory-services-13: a directory saved before the opaque-token +// policy existed keeps today's behavior (userinfo); a new one starts unset +#[test] +fn oidc_directories_saved_before_the_opaque_token_policy_keep_userinfo() { + let saved = OidcDirectory { + description: "Keycloak".to_string(), + issuer_url: "https://sso.example.org/realms/example".to_string(), + require_audience: Some("inbuxa".to_string()), + ..Default::default() + }; + let as_read = OidcDirectory { + opaque_tokens: Some(OidcOpaqueTokens::Userinfo), + ..saved.clone() + }; + + // x:Directory, version 0 + let mut bytes = vec![0u8]; + 2u16.pickle(&mut bytes); + pickle_without_opaque_tokens(&saved, &mut bytes); + assert_eq!( + read(ObjectType::Directory, &bytes), + ObjectInner::Directory(Directory::Oidc(as_read.clone())) + ); + + // x:Bootstrap, version 1, where more fields follow the directory + let bootstrap = Bootstrap { + server_hostname: "mx.example.org".to_string(), + directory: DirectoryBootstrap::Oidc(saved.clone()), + ..Default::default() + }; + let mut bytes = vec![1u8]; + bootstrap.server_hostname.pickle(&mut bytes); + bootstrap.default_domain.pickle(&mut bytes); + bootstrap.request_tls_certificate.pickle(&mut bytes); + bootstrap.generate_dkim_keys.pickle(&mut bytes); + bootstrap.data_store.pickle(&mut bytes); + bootstrap.blob_store.pickle(&mut bytes); + bootstrap.search_store.pickle(&mut bytes); + bootstrap.in_memory_store.pickle(&mut bytes); + 3u16.pickle(&mut bytes); + pickle_without_opaque_tokens(&saved, &mut bytes); + bootstrap.tracer.pickle(&mut bytes); + bootstrap.dns_server.pickle(&mut bytes); + assert_eq!( + read(ObjectType::Bootstrap, &bytes), + ObjectInner::Bootstrap(Bootstrap { + directory: DirectoryBootstrap::Oidc(as_read), + ..bootstrap.clone() + }) + ); + + // Saved now, unset or not, both read back as they were + for directory in [ + saved.clone(), + OidcDirectory { + opaque_tokens: Some(OidcOpaqueTokens::Introspection), + introspection_url: Some("https://sso.example.org/introspect".to_string()), + introspection_client_id: Some("inbuxa".to_string()), + ..saved.clone() + }, + ] { + let bootstrap = Bootstrap { + directory: DirectoryBootstrap::Oidc(directory.clone()), + ..bootstrap.clone() + }; + let directory = Directory::Oidc(directory); + assert_eq!( + read(ObjectType::Directory, &directory.to_pickled_vec()), + ObjectInner::Directory(directory) + ); + assert_eq!( + read(ObjectType::Bootstrap, &bootstrap.to_pickled_vec()), + ObjectInner::Bootstrap(bootstrap) + ); + } +} diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 0772aef0..51cafd4a 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ #![warn(clippy::large_futures)] @@ -11,3 +13,6 @@ pub mod pickle; pub mod schema; pub mod types; pub mod utils; + +#[cfg(test)] +mod inbuxa_tests; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 758a586d..0552e0cc 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1472,6 +1472,17 @@ pub enum NetworkListenerProtocol { ManageSieve = 5, } +// inbuxa: http-directory-services-13: what an OIDC directory does with a +// bearer token that isn't a JWT +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum OidcOpaqueTokens { + #[default] + Reject = 0, + Userinfo = 1, + Introspection = 2, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] #[repr(u16)] pub enum OvhEndpoint { diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 11904695..0a2716e1 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -6675,6 +6675,59 @@ impl<'de> serde::Deserialize<'de> for NetworkListenerProtocol { } } +impl EnumImpl for OidcOpaqueTokens { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"reject" => OidcOpaqueTokens::Reject, + b"userinfo" => OidcOpaqueTokens::Userinfo, + b"introspection" => OidcOpaqueTokens::Introspection, + } + } + + fn as_str(&self) -> &'static str { + match self { + OidcOpaqueTokens::Reject => "reject", + OidcOpaqueTokens::Userinfo => "userinfo", + OidcOpaqueTokens::Introspection => "introspection", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(OidcOpaqueTokens::Reject), + 1 => Some(OidcOpaqueTokens::Userinfo), + 2 => Some(OidcOpaqueTokens::Introspection), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for OidcOpaqueTokens { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for OidcOpaqueTokens { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + impl EnumImpl for OvhEndpoint { fn parse(value: &str) -> Option { hashify::tiny_map! { diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs index e7469dfa..529c205c 100644 --- a/crates/registry/src/schema/properties.rs +++ b/crates/registry/src/schema/properties.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ // This file is auto-generated. Do not edit directly. @@ -982,6 +984,11 @@ pub enum Property { RequestTlsCertificate = 123, Require = 551, RequireAudience = 607, + // inbuxa: http-directory-services-13 + OpaqueTokens = 935, + IntrospectionUrl = 936, + IntrospectionClientId = 937, + IntrospectionClientSecret = 938, RequireClientRegistration = 615, RequireScopes = 608, RequireTls = 525, diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index 461418df..0f53ecb0 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ // This file is auto-generated. Do not edit directly. @@ -1135,6 +1137,10 @@ impl EnumImpl for Property { b"requestTlsCertificate" => Property::RequestTlsCertificate, b"require" => Property::Require, b"requireAudience" => Property::RequireAudience, + b"opaqueTokens" => Property::OpaqueTokens, + b"introspectionUrl" => Property::IntrospectionUrl, + b"introspectionClientId" => Property::IntrospectionClientId, + b"introspectionClientSecret" => Property::IntrospectionClientSecret, b"requireClientRegistration" => Property::RequireClientRegistration, b"requireScopes" => Property::RequireScopes, b"requireTls" => Property::RequireTls, @@ -2075,6 +2081,10 @@ impl EnumImpl for Property { Property::RequestTlsCertificate => "requestTlsCertificate", Property::Require => "require", Property::RequireAudience => "requireAudience", + Property::OpaqueTokens => "opaqueTokens", + Property::IntrospectionUrl => "introspectionUrl", + Property::IntrospectionClientId => "introspectionClientId", + Property::IntrospectionClientSecret => "introspectionClientSecret", Property::RequireClientRegistration => "requireClientRegistration", Property::RequireScopes => "requireScopes", Property::RequireTls => "requireTls", @@ -3019,6 +3029,10 @@ impl EnumImpl for Property { 123 => Some(Property::RequestTlsCertificate), 551 => Some(Property::Require), 607 => Some(Property::RequireAudience), + 935 => Some(Property::OpaqueTokens), + 936 => Some(Property::IntrospectionUrl), + 937 => Some(Property::IntrospectionClientId), + 938 => Some(Property::IntrospectionClientSecret), 615 => Some(Property::RequireClientRegistration), 608 => Some(Property::RequireScopes), 525 => Some(Property::RequireTls), @@ -3230,7 +3244,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 935; + const COUNT: usize = 939; } impl serde::Serialize for Property { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index 7066a572..acf2361d 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ // This file is auto-generated. Do not edit directly. @@ -4142,6 +4144,15 @@ pub struct OidcDirectory { pub claim_groups: Option, #[serde(rename = "memberTenantId")] pub member_tenant_id: Option, + // inbuxa: http-directory-services-13 + #[serde(rename = "opaqueTokens")] + pub opaque_tokens: Option, + #[serde(rename = "introspectionUrl")] + pub introspection_url: Option, + #[serde(rename = "introspectionClientId")] + pub introspection_client_id: Option, + #[serde(rename = "introspectionClientSecret")] + pub introspection_client_secret: SecretKeyOptional, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 96704b7e..02c72ef8 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -4069,7 +4069,9 @@ impl RegistryJsonPropertyPatch for BlockedIp { impl ObjectImpl for Bootstrap { const FLAGS: u64 = OBJ_SINGLETON; - const VERSION: u8 = 1; + // inbuxa: 2, as x:Directory: OidcDirectory gained fields at version 2 + // of both + const VERSION: u8 = 2; const OBJECT: ObjectType = ObjectType::Bootstrap; fn validate(&self, errors: &mut Vec) -> bool { @@ -6990,7 +6992,9 @@ impl RegistryJsonPropertyPatch for DeliveryError { impl ObjectImpl for Directory { const FLAGS: u64 = OBJ_FILTER_TENANT; - const VERSION: u8 = 0; + // inbuxa: 2, as x:Bootstrap: OidcDirectory gained fields at version 2 + // of both + const VERSION: u8 = 2; const OBJECT: ObjectType = ObjectType::Directory; fn validate(&self, errors: &mut Vec) -> bool { @@ -30639,6 +30643,18 @@ impl OidcDirectory { errors.push(ValidationError::required(Property::MemberTenantId)); } } + if let Some(value) = &self.introspection_url { + if value.is_empty() { + errors.push(ValidationError::required(Property::IntrospectionUrl)); + } + } + if let Some(value) = &self.introspection_client_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::IntrospectionClientId)); + } + } + let value = &self.introspection_client_secret; + value.validate(errors); errors.len() == neb } @@ -30661,6 +30677,10 @@ impl Pickle for OidcDirectory { self.claim_name.pickle(out); self.claim_groups.pickle(out); self.member_tenant_id.pickle(out); + self.opaque_tokens.pickle(out); + self.introspection_url.pickle(out); + self.introspection_client_id.pickle(out); + self.introspection_client_secret.pickle(out); } fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { @@ -30674,6 +30694,18 @@ impl Pickle for OidcDirectory { this.claim_name = Pickle::unpickle(stream)?; this.claim_groups = Pickle::unpickle(stream)?; this.member_tenant_id = Pickle::unpickle(stream)?; + // inbuxa: http-directory-services-13: version 2 of both x:Directory + // and x:Bootstrap, the two objects this is stored in. A directory + // saved before keeps accepting opaque tokens its user info endpoint + // accepts, as it always did; a new one refuses them by default. + if stream.version() >= 2 { + this.opaque_tokens = Pickle::unpickle(stream)?; + this.introspection_url = Pickle::unpickle(stream)?; + this.introspection_client_id = Pickle::unpickle(stream)?; + this.introspection_client_secret = Pickle::unpickle(stream)?; + } else { + this.opaque_tokens = Some(OidcOpaqueTokens::Userinfo); + } Some(this) } } @@ -30690,13 +30722,17 @@ impl Default for OidcDirectory { claim_name: Some("name".to_string()), claim_groups: Default::default(), member_tenant_id: Default::default(), + opaque_tokens: Default::default(), + introspection_url: Default::default(), + introspection_client_id: Default::default(), + introspection_client_secret: Default::default(), } } } impl IntoValue for OidcDirectory { fn into_value(self) -> JmapValue<'static> { - let mut map = jmap_tools::Map::with_capacity(11); + let mut map = jmap_tools::Map::with_capacity(15); map.insert_unchecked(Property::Description, self.description.into_value()); map.insert_unchecked(Property::IssuerUrl, self.issuer_url.into_value()); map.insert_unchecked( @@ -30709,6 +30745,19 @@ impl IntoValue for OidcDirectory { map.insert_unchecked(Property::ClaimName, self.claim_name.into_value()); map.insert_unchecked(Property::ClaimGroups, self.claim_groups.into_value()); map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::OpaqueTokens, self.opaque_tokens.into_value()); + map.insert_unchecked( + Property::IntrospectionUrl, + self.introspection_url.into_value(), + ); + map.insert_unchecked( + Property::IntrospectionClientId, + self.introspection_client_id.into_value(), + ); + map.insert_unchecked( + Property::IntrospectionClientSecret, + self.introspection_client_secret.into_value(), + ); JmapValue::Object(map) } } @@ -30745,6 +30794,16 @@ impl RegistryJsonPropertyPatch for OidcDirectory { Some(Property::MemberTenantId) => self .member_tenant_id .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::OpaqueTokens) => self.opaque_tokens.patch(pointer, value), + Some(Property::IntrospectionUrl) => self + .introspection_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::IntrospectionClientId) => self + .introspection_client_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::IntrospectionClientSecret) => { + self.introspection_client_secret.patch(pointer, value) + } Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { property: Property::Type, value, diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index d7ecd26f..4ca396d0 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -9,14 +9,18 @@ use crate::core::Session; use common::{ auth::AuthRequest, + config::smtp::session::Mechanism, network::{SessionStream, legacy::LegacyProtocol}, }; use directory::Credentials; use mail_parser::{HeaderName, HeaderValue, decoders::base64::base64_decode}; use registry::schema::enums::Permission; -use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString}; +use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH, AUTH_XOAUTH2, IntoString}; use trc::AuthEvent; +// inbuxa: mechanisms that send a bearer token, a credential on its own +pub const AUTH_BEARER: u64 = AUTH_OAUTHBEARER | AUTH_XOAUTH | AUTH_XOAUTH2; + pub struct SaslToken { mechanism: u64, credentials: Credentials, @@ -48,6 +52,27 @@ impl SaslToken { } impl Session { + /// The mechanisms this session may use: the configured ones, less the + /// bearer token ones on clear text whatever the configuration says. + pub async fn sasl_mechanisms(&self) -> u64 { + let mechanisms: u64 = self + .server + .eval_if::( + &self.server.core.smtp.session.auth.mechanisms, + self, + self.data.session_id, + ) + .await + .unwrap_or_default() + .into(); + + if self.stream.is_tls() { + mechanisms + } else { + mechanisms & !AUTH_BEARER + } + } + pub async fn handle_sasl_response( &mut self, token: &mut SaslToken, diff --git a/crates/smtp/src/inbound/ehlo.rs b/crates/smtp/src/inbound/ehlo.rs index d82d46b8..64831c85 100644 --- a/crates/smtp/src/inbound/ehlo.rs +++ b/crates/smtp/src/inbound/ehlo.rs @@ -2,13 +2,12 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{core::Session, scripts::ScriptResult}; -use common::{ - config::smtp::session::{Mechanism, Stage}, - network::SessionStream, -}; +use common::{config::smtp::session::Stage, network::SessionStream}; use mail_auth::{ SpfResult, spf::verify::{HasValidLabels, SpfParameters}, @@ -149,7 +148,6 @@ impl Session { response.capabilities |= EXT_START_TLS; } let ec = &self.server.core.smtp.session.extensions; - let ac = &self.server.core.smtp.session.auth; let dc = &self.server.core.smtp.session.data; // Pipelining @@ -214,12 +212,8 @@ impl Session { // Authentication if !self.is_authenticated() { - response.auth_mechanisms = self - .server - .eval_if::(&ac.mechanisms, self, self.data.session_id) - .await - .unwrap_or_default() - .into(); + // inbuxa: no bearer token mechanism on clear text + response.auth_mechanisms = self.sasl_mechanisms().await; if response.auth_mechanisms != 0 { response.capabilities |= EXT_AUTH; } diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs index cae37532..11394997 100644 --- a/crates/smtp/src/inbound/session.rs +++ b/crates/smtp/src/inbound/session.rs @@ -7,7 +7,7 @@ */ use common::{ - config::{server::ServerProtocol, smtp::session::Mechanism}, + config::server::ServerProtocol, expr::{self, functions::ResolveVariable, *}, network::SessionStream, }; @@ -26,7 +26,7 @@ use trc::{NetworkEvent, SecurityEvent, SmtpEvent}; use crate::core::{Session, State}; -use super::auth::SaslToken; +use super::auth::{AUTH_BEARER, SaslToken}; impl Session { pub async fn ingest(&mut self, bytes: &[u8]) -> Result { @@ -102,16 +102,8 @@ impl Session { mechanism, initial_response, } => { - let auth: u64 = self - .server - .eval_if::( - &self.server.core.smtp.session.auth.mechanisms, - self, - self.data.session_id, - ) - .await - .unwrap_or_default() - .into(); + // inbuxa: no bearer token mechanism on clear text + let auth = self.sasl_mechanisms().await; if auth == 0 { trc::event!( Smtp(SmtpEvent::AuthNotAllowed), @@ -127,6 +119,15 @@ impl Session { ); self.write(b"503 5.5.1 Already authenticated.\r\n").await?; + } else if mechanism & AUTH_BEARER != 0 && !self.stream.is_tls() { + trc::event!( + Smtp(SmtpEvent::AuthMechanismNotSupported), + SpanId = self.data.session_id, + Details = "Bearer tokens require TLS", + ); + + self.write(b"538 5.7.11 Bearer tokens require TLS.\r\n") + .await?; } else if let Some(mut token) = SaslToken::from_mechanism(mechanism & auth) { diff --git a/docs/spec/features/per-domain-directories.md b/docs/spec/features/per-domain-directories.md index 4bfdff63..1d368efe 100644 --- a/docs/spec/features/per-domain-directories.md +++ b/docs/spec/features/per-domain-directories.md @@ -148,13 +148,17 @@ Enterprise upstream. Feature 1 covers that field. `TenantStorageQuota` has |---|---|---| | `description` | string | Label | | `issuerUrl` | URI | The provider's issuer. Discovery is fetched from `/.well-known/openid-configuration` | -| `requireAudience` | string, nullable | If set, a token's `aud` must include it | +| `requireAudience` | string, nullable | A token's `aud` must include it. Required on a new directory; one saved without it keeps working, logs a warning each time it loads, and can't have an audience removed once set | | `requireScopes` | set of strings, default `openid`, `email` | Every listed scope must be in the token | | `claimUsername` | string, default `preferred_username` | Claim that names the account | | `usernameDomain` | string, nullable | Appended as `@domain` when the username claim has no `@`. Also appended to bare group names | | `claimName` | string, nullable, default `name` | Claim for the display name | | `claimGroups` | string, nullable | Claim for group memberships | | `memberTenantId` | `Id`, nullable | Tenant (Feature 1) | +| `opaqueTokens` | `reject`, `userinfo` or `introspection`, nullable | What a token that isn't a JWT signs in by (DIR-27). Unset: `introspection` with an `introspectionUrl`, else `reject`. A directory saved before the field existed reads as `userinfo` | +| `introspectionUrl` | URI, nullable | RFC 7662 endpoint. Unset: the discovery document's `introspection_endpoint` | +| `introspectionClientId` | string, nullable | Client ID introspection authenticates with. Required for `introspection` | +| `introspectionClientSecret` | `x:SecretKeyOptional` | Its secret. Required for `introspection` | The LDAP and SQL variants are unchanged, and this feature adds nothing to them. @@ -353,16 +357,30 @@ check it and the fork keeps it. - `HS*` algorithms are refused, and HMAC keys in the JWKS are skipped. RSA (RS and PS), EC P-256 and P-384, and EdDSA are accepted. - The key is found by `kid`. An unknown `kid` refetches the JWKS, at most - once every 300 seconds. With no `kid`, every key is tried. + once every 300 seconds. With no `kid`, every key is tried, and if none + verifies the signature the JWKS is refetched once, under the same limit. + - Discovery, JWKS, userinfo and introspection responses are read up to + 1 MiB; a larger one is a provider error. - `iss` must equal the discovery document's issuer. - `exp` is checked, with 60 seconds of leeway. - If `requireAudience` is set, `aud` must be present and include it. - Every `requireScopes` entry must appear in `scope`, which may be a space-separated string or an array. -- **DIR-27. Opaque tokens.** A token that isn't a JWT is sent to the - provider's userinfo endpoint (OIDC Core §5.3). `401` or `403` means sign-in - fails. Token introspection (RFC 7662) isn't used (public docs), and no - audience or scope check is possible on this path (open question 2). +- **DIR-27. Opaque tokens.** A token that isn't a JWT signs in by the + directory's `opaqueTokens`: + - `reject`: it doesn't. + - `introspection`: it is posted to the introspection endpoint (RFC 7662) + with HTTP Basic client authentication (RFC 6749 §2.3.1). Only + `"active": true` with `iss` equal to the discovery issuer, `aud` + including `requireAudience` when set, `exp` not past, no ID or refresh + marker (as for JWTs) and every `requireScopes` entry signs in. A non-2xx + answer is a provider error, not a failed sign-in. The claims are then + used as a JWT's are (DIR-28). + - `userinfo`: it is sent to the provider's userinfo endpoint (OIDC Core + §5.3), and `401` or `403` means sign-in fails. No audience or scope check + is possible, and any token the provider issued to any application + passes; only directories saved before `opaqueTokens` existed get this + without choosing it. - **DIR-28. Identity.** - The account address is the `claimUsername` claim if it contains `@`. Otherwise it is that claim plus `@usernameDomain`, or failing that the @@ -512,6 +530,9 @@ data. introspection (which needs client credentials the schema has no field for)? The docs name `requireAudience`'s default as `stalwart`, and the schema has no default. Observe which one a new directory gets. + **Settled** (http-directory-services-13): a new directory must set + `requireAudience`, and `opaqueTokens` offers introspection; see the + table and DIR-27. 3. **Local credentials outlive the directory.** App passwords and API keys keep working for a user disabled in the directory (DIR-3). Should synchronization, or a failed directory sign-in, suspend them? diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 9428a47a..436620b8 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -1649,6 +1649,8 @@ secret = ["credential"] [object."x:OidcDirectory"] default = "none" +[object."x:OidcDirectory".properties] +introspectionClientSecret = ["credential"] [object."x:OidcProvider"] default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 9f29577f..ba26e8f9 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 8da7aa5d..d4e520ec 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -F0Ba3aWEThPbP2e1Uy6eryGOZ-UxeLsPNJn9y2yGp4Y \ No newline at end of file +sumxHhh5m_-OICu3KBWCIwgVgSpXxw-jcNAvXt63ATc \ No newline at end of file diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 7bcbe71c..22222375 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -10,23 +10,24 @@ //! runs from `directory_tests`. Each check names its test number or //! requirement. -use crate::utils::{server::TestServerBuilder, smtp::SmtpConnection}; +use crate::utils::{registry::UnwrapRegistryId, server::TestServerBuilder, smtp::SmtpConnection}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use common::{BuildServer, auth::AuthRequest}; use directory::Credentials; use registry::{ schema::{ - enums::TenantStorageQuota, + enums::{OidcOpaqueTokens, TenantStorageQuota}, prelude::{ObjectType, Property}, structs::{ self, CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, - MtaStageRcpt, OidcDirectory, Tenant, UserAccount, + MtaStageRcpt, OidcDirectory, SecretKeyOptional, SecretKeyValue, Tenant, UserAccount, }, }, types::map::Map, }; use serde_json::json; use std::net::IpAddr; +use store::registry::write::RegistryWrite; use trc::{Collector, MetricType}; use types::id::Id; use utils::map::vec_map::VecMap; @@ -55,6 +56,14 @@ fn realm_directory(realm: &str, tenant: Option) -> structs::Directory { "openid".to_string(), ]), member_tenant_id: tenant, + // http-directory-services-13: opaque tokens go to Keycloak's + // introspection endpoint, found by discovery + opaque_tokens: Some(OidcOpaqueTokens::Introspection), + introspection_url: None, + introspection_client_id: Some("stalwart".to_string()), + introspection_client_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "stalwart-secret".to_string(), + }), }) } @@ -169,6 +178,23 @@ pub async fn test() { // example.org signs in against Keycloak; there is no server default let keycloak = admin.registry_create_object(directory(None)).await; + + // http-directory-services-13: a new directory names the audience its + // tokens must be issued for, or tokens minted for any other application + // at the provider would sign in + let mut no_audience = directory(None); + if let structs::Directory::Oidc(oidc) = &mut no_audience { + oidc.require_audience = None; + } + let refused = admin.registry_create_object_expect_err(no_audience).await; + assert!( + refused + .description + .as_deref() + .is_some_and(|reason| reason.contains("audience")), + "a new directory without an audience: {refused:?}" + ); + let domain = admin.find_or_create_domain("example.org").await; admin .registry_update_object( @@ -222,6 +248,153 @@ pub async fn test() { "test 5: the named user" ); + // http-directory-services-13: a directory saved before the audience was + // required keeps signing people in, and can still be edited without + // one; an audience, once set, stays. It also still takes opaque tokens + // its user info endpoint accepts, as it always did. + let mut legacy = directory(None); + if let structs::Directory::Oidc(oidc) = &mut legacy { + oidc.description = "Keycloak, saved without an audience".to_string(); + oidc.require_audience = None; + oidc.opaque_tokens = Some(OidcOpaqueTokens::Userinfo); + oidc.introspection_client_id = None; + oidc.introspection_client_secret = SecretKeyOptional::None; + } + let legacy = test + .server + .registry() + .write(RegistryWrite::insert(&legacy.into())) + .await + .unwrap() + .unwrap_id(trc::location!()); + admin.reload_settings().await; + admin + .registry_update_object( + ObjectType::Directory, + legacy, + json!({ Property::Description: "Keycloak, still without an audience" }), + ) + .await; + let sign_in_through = async |directory: Id, token: &str| { + admin + .registry_update_object( + ObjectType::Domain, + domain, + json!({ Property::DirectoryId: directory.to_string() }), + ) + .await; + // In test mode a leading "." makes any token opaque + let result = bearer(&test, Some(JOHN), token).await; + admin + .registry_update_object( + ObjectType::Domain, + domain, + json!({ Property::DirectoryId: keycloak.to_string() }), + ) + .await; + result + }; + let fresh = token("stalwart", "stalwart-secret", JOHN, "openid email profile") + .await + .unwrap(); + assert_eq!( + sign_in_through(legacy, &fresh).await.unwrap(), + john, + "a directory saved without an audience" + ); + assert_eq!( + sign_in_through(legacy, &format!(".{fresh}")).await.unwrap(), + john, + "an opaque token, through a directory saved before the policy" + ); + + // ... a new directory that doesn't say refuses opaque tokens + let mut unset = directory(None); + if let structs::Directory::Oidc(oidc) = &mut unset { + oidc.description = "Keycloak, opaque tokens unset".to_string(); + oidc.opaque_tokens = None; + oidc.introspection_client_id = None; + oidc.introspection_client_secret = SecretKeyOptional::None; + } + let unset = admin.registry_create_object(unset).await; + assert!( + sign_in_through(unset, &format!(".{fresh}")).await.is_err(), + "an opaque token, through a new directory" + ); + assert_eq!( + sign_in_through(unset, &fresh).await.unwrap(), + john, + "a JWT, through a new directory" + ); + + // ... and introspection takes only an active access token + assert_eq!( + bearer(&test, Some(JOHN), &format!(".{fresh}")) + .await + .unwrap(), + john, + "an opaque token, by introspection" + ); + let refresh = reqwest::Client::new() + .post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token") + .form(&[ + ("grant_type", "password"), + ("client_id", "stalwart"), + ("client_secret", "stalwart-secret"), + ("username", JOHN), + ("password", PASSWORD), + ("scope", "openid email profile"), + ]) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["refresh_token"] + .as_str() + .unwrap() + .to_string(); + for (what, token) in [ + ("a refresh token", format!(".{refresh}")), + ( + "a token the provider never issued", + ".never-issued".to_string(), + ), + ] { + assert!( + bearer(&test, Some(JOHN), &token).await.is_err(), + "by introspection: {what}" + ); + } + let refused = admin + .registry_update_object_expect_err( + ObjectType::Directory, + keycloak, + json!({ Property::IntrospectionClientId: null }), + ) + .await; + assert!( + refused + .description + .as_deref() + .is_some_and(|reason| reason.contains("client ID")), + "introspection without a client: {refused:?}" + ); + let refused = admin + .registry_update_object_expect_err( + ObjectType::Directory, + keycloak, + json!({ Property::RequireAudience: null }), + ) + .await; + assert!( + refused + .description + .as_deref() + .is_some_and(|reason| reason.contains("audience")), + "removing the audience: {refused:?}" + ); + // Test 17: no password sign-in on an OIDC domain (DIR-29) assert!( server(&test) @@ -395,8 +568,8 @@ pub async fn test() { ) .await; admin.reload_settings().await; - // An opaque token has to reach the provider's userinfo endpoint, so with - // the provider stopped this is an outage, not a bad token + // An opaque token has to reach the provider's introspection endpoint, so + // with the provider stopped this is an outage, not a bad token crate::utils::containers::docker("stop", "stalwart-test-keycloak"); for attempt in 0..8 { let err = bearer(&test, Some(JOHN), "an-opaque-token") diff --git a/tests/src/directory/unavailable.rs b/tests/src/directory/unavailable.rs index 23f0fabe..0e0e1b1d 100644 --- a/tests/src/directory/unavailable.rs +++ b/tests/src/directory/unavailable.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use directory::{Credentials, Directory, UnavailableDirectory, backend::oidc::OpenIdDirectory}; @@ -27,6 +29,7 @@ pub async fn test() { require_audience: None, require_scopes: Map::new(vec![]), member_tenant_id: None, + ..Default::default() }) .await .expect_err("Discovery against an unreachable issuer must fail"); diff --git a/tests/src/imap/bearer_tls.rs b/tests/src/imap/bearer_tls.rs new file mode 100644 index 00000000..b7d66ab4 --- /dev/null +++ b/tests/src/imap/bearer_tls.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: bearer tokens are never offered or accepted on a clear-text +//! connection, even with plain-text sign-in allowed. Password sign-in +//! there keeps following that setting. + +use base64::{Engine, engine::general_purpose}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, + net::{TcpStream, tcp::OwnedReadHalf}, +}; + +// Expects plain-text sign-in to be allowed, as the imap suite leaves it. +pub async fn test() { + println!("Running clear-text bearer token tests..."); + + let token = general_purpose::STANDARD + .encode("n,a=jdoe@example.com,\u{1}auth=Bearer not-a-real-token\u{1}\u{1}"); + let mut problems = Vec::new(); + + // IMAP, clear text with STARTTLS available + let (mut rx, mut tx) = connect("127.0.0.1:9991").await; + let greeting = rx.next_line().await.unwrap().unwrap(); + check_offer( + &mut problems, + "IMAP greeting", + &greeting, + "AUTH=PLAIN", + &["AUTH=OAUTHBEARER", "AUTH=XOAUTH2"], + ); + tx.write_all(b"a CAPABILITY\r\n").await.unwrap(); + let capability = read_until(&mut rx, "a ").await.join("\n"); + check_offer( + &mut problems, + "IMAP CAPABILITY", + &capability, + "AUTH=PLAIN", + &["AUTH=OAUTHBEARER", "AUTH=XOAUTH2"], + ); + tx.write_all(format!("b AUTHENTICATE OAUTHBEARER {token}\r\n").as_bytes()) + .await + .unwrap(); + let reply = read_until(&mut rx, "b ").await.join("\n"); + if !reply.contains("b NO [PRIVACYREQUIRED]") { + problems.push(format!( + "IMAP AUTHENTICATE OAUTHBEARER not refused: {reply}" + )); + } + tx.write_all(b"c AUTHENTICATE XOAUTH2\r\n").await.unwrap(); + let reply = rx.next_line().await.unwrap().unwrap(); + if !reply.starts_with("c NO [PRIVACYREQUIRED]") { + problems.push(format!("IMAP AUTHENTICATE XOAUTH2 not refused: {reply}")); + } + drop(tx); + + // POP3, clear text + let (mut rx, mut tx) = connect("127.0.0.1:4111").await; + assert!(rx.next_line().await.unwrap().unwrap().starts_with("+OK")); + tx.write_all(b"CAPA\r\n").await.unwrap(); + let capa = read_until(&mut rx, ".").await.join("\n"); + check_offer( + &mut problems, + "POP3 CAPA", + &capa, + "SASL PLAIN", + &["OAUTHBEARER", "XOAUTH2"], + ); + for (mechanism, param) in [("OAUTHBEARER", token.as_str()), ("XOAUTH2", "")] { + tx.write_all( + format!("AUTH {mechanism} {param}\r\n") + .trim_end() + .as_bytes(), + ) + .await + .unwrap(); + tx.write_all(b"\r\n").await.unwrap(); + let reply = rx.next_line().await.unwrap().unwrap(); + if !(reply.starts_with("-ERR") && reply.contains("TLS")) { + problems.push(format!("POP3 AUTH {mechanism} not refused: {reply}")); + } + } + drop(tx); + + // ManageSieve, clear text with STARTTLS available + let (mut rx, mut tx) = connect("127.0.0.1:4191").await; + let greeting = read_until(&mut rx, "OK").await.join("\n"); + check_offer( + &mut problems, + "ManageSieve greeting", + &greeting, + "PLAIN", + &["OAUTHBEARER", "XOAUTH2"], + ); + for (mechanism, param) in [ + ("OAUTHBEARER", format!(" \"{token}\"")), + ("XOAUTH2", "".into()), + ] { + tx.write_all(format!("AUTHENTICATE \"{mechanism}\"{param}\r\n").as_bytes()) + .await + .unwrap(); + let reply = rx.next_line().await.unwrap().unwrap(); + if !reply.starts_with("NO (ENCRYPT-NEEDED)") { + problems.push(format!( + "ManageSieve AUTHENTICATE {mechanism} not refused: {reply}" + )); + } + } + drop(tx); + + assert!( + problems.is_empty(), + "bearer tokens on clear text:\n{}", + problems.join("\n") + ); +} + +fn check_offer(problems: &mut Vec, what: &str, text: &str, keep: &str, drop: &[&str]) { + if !text.contains(keep) { + problems.push(format!("{what} lost {keep}: {text}")); + } + for mechanism in drop { + if text.contains(mechanism) { + problems.push(format!("{what} offered {mechanism}: {text}")); + } + } +} + +async fn connect( + addr: &str, +) -> ( + Lines>, + tokio::net::tcp::OwnedWriteHalf, +) { + let (rx, tx) = TcpStream::connect(addr).await.unwrap().into_split(); + (BufReader::new(rx).lines(), tx) +} + +async fn read_until(rx: &mut Lines>, prefix: &str) -> Vec { + let mut lines = Vec::new(); + loop { + let line = rx.next_line().await.unwrap().unwrap(); + let done = line.starts_with(prefix); + lines.push(line); + if done { + return lines; + } + } +} diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 32065a8c..57c6405f 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -10,6 +10,7 @@ pub mod acl; pub mod antispam; pub mod append; pub mod basic; +pub mod bearer_tls; pub mod body_structure; pub mod condstore; pub mod copy_move; @@ -19,6 +20,7 @@ pub mod mailbox; pub mod managesieve; pub mod messagelimit; pub mod objectid; +pub mod pipelining; pub mod pop; pub mod search; pub mod store; @@ -55,6 +57,13 @@ pub async fn imap_tests() { .await .with_listener(NetworkListenerProtocol::Pop3, "pop3-plain", 4111, false) .await + .with_listener( + NetworkListenerProtocol::ManageSieve, + "sieve-plain", + 4191, + false, + ) + .await .build() .await; @@ -266,6 +275,7 @@ pub async fn imap_tests() { acl::test(&mut imap, &mut imap_check, &test).await; uidbatches::test(&mut imap, &mut imap_check).await; messagelimit::test(&mut imap, &mut imap_check).await; + pipelining::test(&test).await; // UIDONLY cannot be disabled once enabled, so it uses its own connection uidonly::test(&test).await; @@ -288,6 +298,9 @@ pub async fn imap_tests() { // Run POP3 tests pop::test(&test).await; + // Bearer tokens on clear-text connections + bearer_tls::test().await; + // Print elapsed time let elapsed = start_time.elapsed(); println!( diff --git a/tests/src/imap/pipelining.rs b/tests/src/imap/pipelining.rs new file mode 100644 index 00000000..b408286f --- /dev/null +++ b/tests/src/imap/pipelining.rs @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: commands a client pipelines in one write run in order. A +//! command that changes state waits for the commands before it and the +//! commands after it wait for it, so they see its result. + +use crate::utils::server::TestServer; +use base64::{Engine, engine::general_purpose}; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, + net::{TcpStream, tcp::OwnedReadHalf}, +}; + +const MESSAGES: usize = 8; + +pub async fn test(test: &TestServer) { + println!("Running pipelined command ordering tests..."); + + let account = test.account("jdoe@example.com"); + let (rx, mut tx) = TcpStream::connect("127.0.0.1:9991") + .await + .unwrap() + .into_split(); + let mut rx = BufReader::new(rx).lines(); + rx.next_line().await.unwrap().unwrap(); + let credentials = + general_purpose::STANDARD.encode(format!("\0{}\0{}", account.name(), account.secret())); + tx.write_all( + format!( + "a AUTHENTICATE PLAIN {{{}+}}\r\n{credentials}\r\n", + credentials.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + let login = read_tags(&mut rx, &["a"]).await; + assert!( + login + .tagged + .first() + .is_some_and(|line| line.starts_with("a OK")), + "sign-in failed: {:?}", + login.lines + ); + + let mut problems = Vec::new(); + + // CREATE, then APPENDs into the new mailbox, then STATUS, in one write + let mut script = String::from("p00 CREATE \"Pipelined\"\r\n"); + let mut tags = vec!["p00".to_string()]; + for i in 1..=MESSAGES { + let message = + format!("From: pipe@example.com\r\nSubject: pipelined {i}\r\n\r\nbody {i}\r\n"); + script.push_str(&format!( + "p{i:02} APPEND \"Pipelined\" {{{}+}}\r\n{message}\r\n", + message.len() + )); + tags.push(format!("p{i:02}")); + } + let status_tag = format!("p{:02}", MESSAGES + 1); + script.push_str(&format!("{status_tag} STATUS \"Pipelined\" (MESSAGES)\r\n")); + tags.push(status_tag); + tx.write_all(script.as_bytes()).await.unwrap(); + let tags = tags.iter().map(String::as_str).collect::>(); + let result = read_tags(&mut rx, &tags).await; + result.check_in_order(&mut problems, "CREATE/APPEND/STATUS", &tags); + let expected = format!("MESSAGES {MESSAGES})"); + if !result + .lines + .iter() + .any(|line| line.starts_with("* STATUS") && line.contains(&expected)) + { + problems.push(format!( + "STATUS after the pipelined APPENDs did not report {expected}: {:?}", + result.untagged("* STATUS") + )); + } + + // STORE, then FETCH of the same messages, then DELETE, in one write. + // SELECT goes first on its own: the commands after it are checked + // against the session state as they are read. + tx.write_all(b"q1 SELECT \"Pipelined\"\r\n").await.unwrap(); + let result = read_tags(&mut rx, &["q1"]).await; + result.check_in_order(&mut problems, "SELECT", &["q1"]); + let tags = ["q2", "q3", "q4", "q5"]; + tx.write_all( + concat!( + "q2 STORE 1:* +FLAGS.SILENT (\\Flagged)\r\n", + "q3 FETCH 1:* (FLAGS)\r\n", + "q4 UNSELECT\r\n", + "q5 DELETE \"Pipelined\"\r\n", + ) + .as_bytes(), + ) + .await + .unwrap(); + let result = read_tags(&mut rx, &tags).await; + result.check_in_order(&mut problems, "STORE/FETCH/UNSELECT/DELETE", &tags); + let fetched = result.untagged("FETCH ("); + if fetched.len() != MESSAGES || fetched.iter().any(|line| !line.contains("\\Flagged")) { + problems.push(format!( + "FETCH after the pipelined STORE did not see \\Flagged on all {MESSAGES} messages: {fetched:?}" + )); + } + + tx.write_all(b"z LOGOUT\r\n").await.unwrap(); + + assert!(problems.is_empty(), "{}", problems.join("\n")); +} + +struct Responses { + lines: Vec, + tagged: Vec, +} + +impl Responses { + fn check_in_order(&self, problems: &mut Vec, what: &str, tags: &[&str]) { + let order = self + .tagged + .iter() + .map(|line| line.split_once(' ').map_or(line.as_str(), |(tag, _)| tag)) + .collect::>(); + if order != tags { + problems.push(format!( + "{what}: completions out of order: {:?}", + self.tagged + )); + } + for line in &self.tagged { + if !line.split(' ').nth(1).is_some_and(|status| status == "OK") { + problems.push(format!("{what}: command failed: {line:?}")); + } + } + } + + fn untagged(&self, needle: &str) -> Vec<&String> { + self.lines + .iter() + .filter(|line| line.starts_with("* ") && line.contains(needle)) + .collect() + } +} + +// Reads until every tag has completed, keeping the order they completed in +async fn read_tags(rx: &mut Lines>, tags: &[&str]) -> Responses { + let mut responses = Responses { + lines: Vec::new(), + tagged: Vec::new(), + }; + while responses.tagged.len() < tags.len() { + let line = match tokio::time::timeout(Duration::from_secs(15), rx.next_line()).await { + Ok(Ok(Some(line))) => line, + other => panic!( + "no response for all of {tags:?} ({other:?}); got {:?}", + responses.lines + ), + }; + if tags.iter().any(|tag| { + line.strip_prefix(tag) + .is_some_and(|rest| rest.starts_with(' ')) + }) { + responses.tagged.push(line.clone()); + } + responses.lines.push(line); + } + responses +} diff --git a/tests/src/scim/oidc.rs b/tests/src/scim/oidc.rs index 62ea9114..736085a6 100644 --- a/tests/src/scim/oidc.rs +++ b/tests/src/scim/oidc.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -57,6 +59,7 @@ async fn bind_directory(test: &TestServer) -> Id { "openid".to_string(), ]), member_tenant_id: None, + ..Default::default() })) .await; diff --git a/tests/src/smtp/inbound/auth.rs b/tests/src/smtp/inbound/auth.rs index b8b4eebf..a86a554f 100644 --- a/tests/src/smtp/inbound/auth.rs +++ b/tests/src/smtp/inbound/auth.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -67,10 +69,17 @@ async fn auth() { else_: "false".into(), }, sasl_mechanisms: Expression { - match_: List::from_iter([ExpressionMatch { - if_: "remote_ip = '10.0.0.1' && is_tls".into(), - then: "[plain, login]".into(), - }]), + match_: List::from_iter([ + ExpressionMatch { + if_: "remote_ip = '10.0.0.1' && is_tls".into(), + then: "[plain, login]".into(), + }, + // inbuxa: every mechanism allowed with or without TLS + ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "[plain, login, oauthbearer, xoauth2]".into(), + }, + ]), else_: "0".into(), }, wait_on_fail: Expression { @@ -175,4 +184,31 @@ async fn auth() { session .auth_plain("john@example.org", "12345 + extra safety", "503 5.5.1") .await; + + // inbuxa: bearer tokens are neither offered nor accepted without TLS, + // even where the configuration allows every mechanism there + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.3".into(); + session.eval_session_params().await; + session.stream.tls = false; + session + .ehlo("mx.foobar.org") + .await + .assert_contains(" PLAIN") + .assert_not_contains("OAUTHBEARER") + .assert_not_contains("XOAUTH2"); + session + .ingest(b"AUTH OAUTHBEARER bixhPWpvaG5AZXhhbXBsZS5vcmcsAWF1dGg9QmVhcmVyIHgBAQ==\r\n") + .await + .unwrap(); + session.response().assert_code("538 5.7.11"); + session.ingest(b"AUTH XOAUTH2\r\n").await.unwrap(); + session.response().assert_code("538 5.7.11"); + session.stream.tls = true; + session + .ehlo("mx.foobar.org") + .await + .assert_contains(" PLAIN") + .assert_contains("OAUTHBEARER") + .assert_contains("XOAUTH2"); } diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index a22795e8..38f3f0f8 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -8,7 +8,7 @@ use crate::utils::{ http::HttpRequest, - imap::{ImapConnection, Type}, + imap::{AssertResult, ImapConnection, Type}, pop3::Pop3Connection, server::TestServer, smtp::SmtpConnection, @@ -39,9 +39,15 @@ use registry::schema::{ prelude::{ObjectType, Property}, structs::{OAuthClient, OidcProvider, SecretText, SecretTextValue}, }; +use rustls_pki_types::ServerName; use serde::{Serialize, de::DeserializeOwned}; use std::time::{Duration, Instant}; use store::ahash::AHashMap; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}, + net::TcpStream, +}; +use utils::tls::build_tls_connector; #[derive(Debug, serde::Deserialize)] pub struct OAuthMetadata { @@ -560,20 +566,31 @@ pub async fn test(test: &mut TestServer) { "n,a={},\u{1}auth=Bearer {}\u{1}\u{1}", "user@domain", token )); + // inbuxa: bearer tokens are refused on clear text, so the sign-ins that + // should work run over STARTTLS (SMTP) and implicit TLS (IMAP) let mut smtp = SmtpConnection::connect().await; - smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_invalid_sasl}",)) - .await; - smtp.read(1, 4).await; smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}",)) .await; - smtp.read(1, 2).await; + smtp.read(1, 5).await; + let replies = lmtp_starttls_auth(&[&oauth_bearer_invalid_sasl, &oauth_bearer_sasl]).await; + assert!( + replies[0].starts_with('4') && replies[1].starts_with("235"), + "SMTP OAUTHBEARER over TLS: {replies:?}" + ); // Try IMAP OAUTHBEARER auth let mut imap = ImapConnection::connect(b"_x ").await; imap.assert_read(Type::Untagged, ResponseType::Ok).await; imap.send(&format!("AUTHENTICATE OAUTHBEARER {oauth_bearer_sasl}")) .await; - imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.assert_read(Type::Tagged, ResponseType::No) + .await + .assert_contains("PRIVACYREQUIRED"); + let reply = imap_tls_auth(&oauth_bearer_sasl).await; + assert!( + reply.starts_with("a OK"), + "IMAP OAUTHBEARER over TLS: {reply}" + ); // Try POP3 OAUTHBEARER auth let mut pop3 = Pop3Connection::connect().await; @@ -1260,3 +1277,81 @@ WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn 0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA -----END PRIVATE KEY----- "; + +// inbuxa: AUTH OAUTHBEARER with each SASL response on the LMTP port after +// STARTTLS, one attempt per response; returns the final reply lines. +async fn lmtp_starttls_auth(sasl: &[&str]) -> Vec { + let mut stream = BufReader::new(TcpStream::connect("127.0.0.1:11200").await.unwrap()); + read_smtp_reply(&mut stream).await; + stream + .get_mut() + .write_all(b"LHLO localhost\r\n") + .await + .unwrap(); + read_smtp_reply(&mut stream).await; + stream.get_mut().write_all(b"STARTTLS\r\n").await.unwrap(); + assert!(read_smtp_reply(&mut stream).await.starts_with("220")); + let tls = build_tls_connector(true) + .unwrap() + .connect( + ServerName::try_from("localhost").unwrap().to_owned(), + stream.into_inner(), + ) + .await + .unwrap(); + let mut stream = BufReader::new(tls); + stream + .get_mut() + .write_all(b"LHLO localhost\r\n") + .await + .unwrap(); + read_smtp_reply(&mut stream).await; + let mut replies = Vec::new(); + for response in sasl { + stream + .get_mut() + .write_all(format!("AUTH OAUTHBEARER {response}\r\n").as_bytes()) + .await + .unwrap(); + replies.push(read_smtp_reply(&mut stream).await); + } + replies +} + +async fn read_smtp_reply(stream: &mut BufReader) -> String { + loop { + let mut line = String::new(); + stream.read_line(&mut line).await.unwrap(); + if line.len() < 4 || line.as_bytes()[3] == b' ' { + return line; + } + } +} + +// inbuxa: AUTHENTICATE OAUTHBEARER on the implicit TLS IMAP port; returns the +// tagged reply. +async fn imap_tls_auth(sasl: &str) -> String { + let tls = build_tls_connector(true) + .unwrap() + .connect( + ServerName::try_from("localhost").unwrap().to_owned(), + TcpStream::connect("127.0.0.1:9992").await.unwrap(), + ) + .await + .unwrap(); + let mut stream = BufReader::new(tls); + let mut line = String::new(); + stream.read_line(&mut line).await.unwrap(); + stream + .get_mut() + .write_all(format!("a AUTHENTICATE OAUTHBEARER {sasl}\r\n").as_bytes()) + .await + .unwrap(); + loop { + line.clear(); + stream.read_line(&mut line).await.unwrap(); + if line.starts_with("a ") || line.is_empty() { + return line; + } + } +} diff --git a/vendor/calcard/.gitignore b/vendor/calcard/.gitignore new file mode 100644 index 00000000..3b747458 --- /dev/null +++ b/vendor/calcard/.gitignore @@ -0,0 +1,12 @@ +_ignore +Cargo.lock +target +web/dist +specs/ +.DS_Store +*.md +!CHANGELOG.md +!README.md + +# inbuxa: the vendoring notes are tracked +!VENDORED.md diff --git a/vendor/calcard/CHANGELOG.md b/vendor/calcard/CHANGELOG.md new file mode 100644 index 00000000..78f8e7b6 --- /dev/null +++ b/vendor/calcard/CHANGELOG.md @@ -0,0 +1,117 @@ +calcard 0.3.14 +================================ +- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-26` and `draft-ietf-calext-jscalendarbis-20`. +- Recurrence overrides converted from iCalendar no longer contain patches forbidden by `jscalendarbis` (`privacy`, `organizerCalendarAddress`, `sentBy`, `recurrenceRule`, nested `recurrenceOverrides`, ...), and such patches are ignored when converting to iCalendar; an override holding only forbidden patches is dropped, and a more restrictive `privacy` patch raises the privacy of the whole object. +- All recurrences of a calendar object now convert to a single `privacy` value, taken from the most restrictive `CLASS` among the main component, its recurrence overrides, or its recurrence instances (or among repeated `CLASS` properties in one component). +- Unknown `CLASS` values now convert to the `private` privacy, preserving the original property in `iCalendar/properties`. +- The `privacy` property now converts to the `CLASS` property of the main component and every recurrence override component, and a preserved `CLASS` with an unknown value is restored instead of `PRIVATE`, never emitting more than one `CLASS` per component. +- Recurrence override components exported from JSCalendar now keep the `ORGANIZER` property of the main component when the override's participants include the organizer. +- Bump `mail-builder` dependency to 1.0.0 and `uuid` to 1.26. + +calcard 0.3.13 +================================ +- Fix: serialized lines exceeded the 75 octet fold (#25). +- General performance improvements. + +calcard 0.3.12 +================================ +- Added `ICalendar::add_missing_timezones()`, which builds a `VTIMEZONE` component for every `TZID` parameter that has no matching definition, as required by RFC 5545, Section 3.6.5. +- Added `ICalendar::add_timezone()`, which builds a `VTIMEZONE` definition for a given time zone identifier over an explicit UTC range. + +calcard 0.3.11 +================================ +- Fix: whitespace immediately preceding a fold was discarded while unfolding, silently joining the words around it (RFC 5545, Section 3.1). +- Fix: `into_jscalendar()` panicked on `VJOURNAL`, `VFREEBUSY`, `VTIMEZONE` and unknown components, none of which have a JSCalendar type. +- Fix: `RRULE` values that could not be parsed were truncated to their first rule part instead of being preserved verbatim. +- Fix: unparseable `RRULE` values were serialized with `TEXT` escaping, turning the structural `;` and `,` separators of the `RECUR` value type into `\;` and `\,` (RFC 5545, Section 3.3.10). +- Fix: date-only vCard 3.0 `REV` values were serialized with a fabricated `T000000` time, although a `date` value is valid there (RFC 2426, Section 3.6.4). +- Fix: truncated iCalendar `DATE-TIME` and vCard `TIMESTAMP` values were not completed, so a value missing its seconds did not survive a round trip, and a value missing its minutes was silently downgraded to a `DATE`, discarding the time. +- Fix: control characters were retained in parameter values and emitted verbatim by URI parameters such as `ALTREP` and `DIR`, although `QSAFE-CHAR` does not permit them (RFC 5545, Section 3.1). +- Fix: vCard content lines lacking a value separator were parsed with no value but serialized with an empty one. +- Fix: serialized lines exceeded the 75 octet limit by one because the colon separating a property from its value was not counted. +- Fix: an empty value at a fold boundary emitted a continuation line holding nothing but the fold character. +- Fix: vCard 2.1 `BASE64` values continued on unindented lines and terminated by a blank line were truncated to their first line, discarding the rest of the encoded data. +- Fix: an `RRULE` holding an invalid rule part silently yielded a partial recurrence rule, since every part after the invalid one was skipped and the resulting rule was returned as if it had parsed cleanly. +- Expanded the iCalendar and vCard round-trip test corpus with samples collected from public test suites. + +calcard 0.3.10 +================================ +- Bump `mail-builder` dependency to 0.5. +- Use `rkyv::primitive::ArchivedU32` instead of the concrete `rkyv::rend::u32_le`. + +calcard 0.3.9 +================================ +- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-25`. +- Fix: `ORGANIZER` converted to a duplicate Participant object instead of merging with the `ATTENDEE` or `PARTICIPANT` sharing its calendar address (#24). +- Fix: the `owner` role was not set on the Participant object converted from `ORGANIZER`. +- Fix: `iCalendar` converted properties of Link and Participant objects were matched position rather than by object key when no `JSID` parameter was present. +- Fix: all-day events exported as a floating DATE-TIME plus `SHOW-WITHOUT-TIME` instead of the `DATE` value type. +- Fix: `VERSION` was missing from the generated `VCALENDAR` component, and the JSCalendar `version` property was exported as a `JSPROP` property. + +calcard 0.3.8 +================================ +- Fix: binary values without a media type serialized as `data:base64,`, which is not a valid + `data` URL (RFC 2397). +- Map the legacy vCard 3.0 `TYPE` parameter of `PHOTO`, `LOGO`, `SOUND` and `KEY` to the media + type of their binary value, so upgrading a v3.0 card to v4.0 yields `data:image/jpeg;base64,` + instead of a typeless `data` URL. +- Include `TYPE=` when exporting binary values to vCard v3.0 and below. +- Fix: archived iCalendar writer escaped `,` and `;` inside quoted URI parameter values such as + `DIR`. + +calcard 0.3.7 +================================ +- Fix: JSCalendar `rscale` not converted to the iCalendar `RSCALE` rule part. +- Fix: `mailto:` scheme not stripped from calendar addresses when uppercase or mixed case. + +calcard 0.3.6 +================================ +- Fix: `FN` property not generated for vCard when `N` property is present (#22). + +calcard 0.3.5 +================================ +- Include 'ENCODING=' when exporting vCard v3.0 and below. + +calcard 0.3.4 +================================ +- Include `CHARSET=UTF-8` when exporting vCard v3.0 and below. + +calcard 0.3.3 +================================ +- Support `STATUS:CANCELLED` mapping from `VTODO` to JSCalendar (#20). +- Fixed duration parsing for zero duration `PT0S`. + +calcard 0.3.2 +================================ +- Fixed vCard `CELL` to JSContact `mobile` mapping (#15). +- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-21`. + +calcard 0.3.1 +================================ +- Fixed jcal implementation to use lowercase property and component names. +- Updated conversion rules according to the latest JSCalendar-bis specification. + +calcard 0.3.0 +================================ +- JMAP for Calendars support. +- JMAP for Contacts support. + +calcard 0.2.0 +================================ +- JSCalendar parsing and conversion to iCalendar format. +- JSContact parsing and conversion to vCard format. +- Fix: `RRULE` with `UNTIL` dates not parsed correctly (#12). +- Fix: Support multiple periods in `FREEBUSY` component (#4). +- Fix: Incorrect `\ ` conversion (#3). + +calcard 0.1.3 +================================ +- Added some builder methods. + +calcard 0.1.1 +================================ +- Export vCard in legacy formats. + +calcard 0.1.0 +================================ +- Initial release. diff --git a/vendor/calcard/Cargo.toml b/vendor/calcard/Cargo.toml new file mode 100644 index 00000000..6b113e25 --- /dev/null +++ b/vendor/calcard/Cargo.toml @@ -0,0 +1,116 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "calcard" +version = "0.3.14" +authors = ["Stalwart Labs "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "iCalendar/JSCalendar and vCard/JSContact parsing, building and conversion library for Rust" +homepage = "https://github.com/stalwartlabs/calcard" +readme = "README.md" +keywords = [ + "icalendar", + "vcard", + "jscalendar", + "jscontact", + "parser", +] +categories = [ + "date-and-time", + "email", + "parser-implementations", +] +license = "Apache-2.0 OR MIT" +repository = "https://github.com/stalwartlabs/calcard" +resolver = "2" + +[features] +default = ["jmap"] +jmap = [ + "jmap-tools", + "uuid", + "serde", + "serde_json", +] +rkyv = ["dep:rkyv"] +serde = [ + "dep:serde", + "chrono/serde", +] +wasm = ["hashify/force-32bit"] + +[lib] +name = "calcard" +path = "src/lib.rs" +doctest = false + +[dependencies.ahash] +version = "0.8.11" + +[dependencies.chrono] +version = "0.4.40" + +[dependencies.chrono-tz] +version = "0.10.3" + +[dependencies.hashify] +version = "0.2.7" + +[dependencies.jmap-tools] +version = "0.1" +optional = true + +[dependencies.mail-builder] +version = "1.0.0" +default-features = false + +[dependencies.mail-parser] +version = "0.11" + +[dependencies.rkyv] +version = "0.8" +optional = true + +[dependencies.serde] +version = "1.0" +features = ["derive"] +optional = true + +[dependencies.serde_json] +version = "1.0" +optional = true + +[dependencies.uuid] +version = "1.26" +features = ["v5"] +optional = true +default-features = false + +[dev-dependencies.chrono] +version = "0.4" +features = ["serde"] + +[dev-dependencies.rkyv] +version = "0.8" + +[dev-dependencies.serde] +version = "1.0" +features = ["derive"] + +[dev-dependencies.serde_json] +version = "1.0" diff --git a/vendor/calcard/LICENSES/Apache-2.0.txt b/vendor/calcard/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000..16fe87b0 --- /dev/null +++ b/vendor/calcard/LICENSES/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/calcard/LICENSES/MIT.txt b/vendor/calcard/LICENSES/MIT.txt new file mode 100644 index 00000000..31aa7938 --- /dev/null +++ b/vendor/calcard/LICENSES/MIT.txt @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/calcard/README.md b/vendor/calcard/README.md new file mode 100644 index 00000000..275d6d0b --- /dev/null +++ b/vendor/calcard/README.md @@ -0,0 +1,224 @@ +# calcard + +[![crates.io](https://img.shields.io/crates/v/calcard)](https://crates.io/crates/calcard) +[![build](https://github.com/stalwartlabs/calcard/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/calcard/actions/workflows/rust.yml) +[![docs.rs](https://img.shields.io/docsrs/calcard)](https://docs.rs/calcard) +[![crates.io](https://img.shields.io/crates/l/calcard)](http://www.apache.org/licenses/LICENSE-2.0) + +`calcard` is a Rust crate for parsing, generating, and converting calendaring and contact data across multiple formats. It supports iCalendar and vCard, making it easy to work with `.ics` and `.vcf` files. It also fully handles JSCalendar and JSContact, the emerging standards commonly used in JMAP-based systems. In addition to parsing and serializing these formats, `calcard` provides seamless conversion between iCalendar and JSCalendar, as well as between vCard and JSContact, enabling smooth interoperability between traditional and modern calendaring and contact protocols. + +In general, this library abides by the Postel's law or [Robustness Principle](https://en.wikipedia.org/wiki/Robustness_principle) which states that an implementation must be conservative in its sending behavior and liberal in its receiving behavior. This means that `calcard` will make a best effort to parse non-conformant iCal/vCard/JSCalendar/JSContact objects as long as these do not deviate too much from the standard. + +## Features + +Comprehensive features for working with calendaring and contact data, including: + +- **iCalendar** support: Parsing and generating iCalendar data. +- **vCard** support: Parsing and generating vCard data. +- **JSCalendar** support: Parsing and generating JSCalendar data. +- **JSContact** support: Parsing and generating JSContact data. +- Seamless conversion between **iCalendar** and **JSCalendar**. +- Seamless conversion between **vCard** and **JSContact**. +- **Recurrence rules expansion**: Accurately computes and enumerates repeating events based on iCalendar and JSCalendar RRULEs. +- **IANA timezone detection**: Automatically resolves and handles custom and proprietary timezones. + +## Usage + +### Parsing an iCalendar and/or vCard stream + +You can parse vCard and iCalendar files using the stream-based parser: + +```rust +let input = "BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nEND:VCARD\n"; +let mut parser = Parser::new(&input); + +loop { + match parser.entry() { + Entry::VCard(vcard) => println!("Parsed VCard: {:?}", vcard), + Entry::ICalendar(ical) => println!("Parsed ICalendar: {:?}", ical), + Entry::InvalidLine(line) => eprintln!("Invalid line found: {}", line), + Entry::UnexpectedComponentEnd { expected, found } => { + eprintln!("Unexpected end: expected {:?}, found {:?}", expected, found); + }, + Entry::UnterminatedComponent(component) => { + eprintln!("Unterminated component: {:?}", component); + }, + Entry::Eof => { + break; + } + } +} +``` + +### Parsing iCalendar + +You can parse a single iCalendar using the `ICalendar::parse` method: + +```rust +let input = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n"; +let ical = ICalendar::parse(&input); + +println!("Parsed ICalendar: {:?}", ical); +``` + +### Parsing vCard + +You can parse a single vCard using the `VCard::parse` method: + +```rust +let input = "BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nEND:VCARD\n"; +let vcard = VCard::parse(&input); + +println!("Parsed VCard: {:?}", vcard); +``` +### Parsing JSCalendar + +You can parse a JSCalendar JSON string using the `JSCalendar::parse` method: + +```rust +let input = r#"{ + "@type": "Group", + "uid": "bf0ac22b-4989-4caf-9ebd-54301b4ee51a", + "updated": "2020-01-15T18:00:00Z", + "title": "A simple group", + "entries": [{ + "@type": "Event", + "uid": "a8df6573-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "Some event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H" + }, + { + "@type": "Task", + "uid": "2a358cee-6489-4f14-a57f-c104db4dc2f2", + "updated": "2020-01-09T14:32:01Z", + "title": "Do something" + }] + }"#; + +let jscalendar = JSCalendar::::parse(input).unwrap(); +println!("Parsed JSCalendar: {}", jscalendar.to_string_pretty()); +``` + +### Parsing JSContact + +You can parse a JSContact JSON string using the `JSContact::parse` method: + +```rust +let input = r#"{ + "@type": "Card", + "version": "1.0", + "uid": "22B2C7DF-9120-4969-8460-05956FE6B065", + "kind": "individual", + "name": { + "components": [ + { "kind": "given", "value": "John" }, + { "kind": "surname", "value": "Doe" } + ], + "isOrdered": true + } + }"#; + +let jscontact = JSContact::::parse(input).unwrap(); +println!("Parsed JSContact: {}", jscontact.to_string_pretty()); +``` + +### Converting to/from iCalendar and JSCalendar + +To convert a JSCalendar to an iCalendar, use the `JSCalendar::into_icalendar` method: + +```rust +let ical = JSCalendar::::parse(input).unwrap().into_icalendar().unwrap(); +``` + +To convert an iCalendar to a JSCalendar, use the `ICalendar::into_jscalendar` method: + +```rust +let jscalendar = ICalendar::parse(&input).unwrap().into_jscalendar::().unwrap(); +``` + +### Converting to/from vCard and JSContact + +To convert a JSContact to a vCard, use the `JSContact::into_vcard` method: + +```rust +let vcard = JSContact::::parse(input).unwrap().into_vcard().unwrap(); +``` + +To convert a vCard to a JSContact, use the `VCard::into_jscontact` method: + +```rust +let jscontact = VCard::parse(&input).unwrap().into_jscontact::().unwrap(); +``` + +### Generating iCalendar and vCard + +To generate a vCard or iCalendar, simply call `.to_string()` on the parsed object: + +```rust +let vcard_string = vcard.to_string(); +println!("Generated vCard:\n{}", vcard_string); + +let ical_string = ical.to_string(); +println!("Generated ICalendar:\n{}", ical_string); +``` + +*Note: Documentation for creating VCard and ICalendar objects is coming soon.* + +### Generating JSCalendar and JSContact + +To generate a JSCalendar or JSContact, simply call `.to_string()` on the parsed object or `.to_string_pretty()` for a more human-readable format: + +```rust +let jscalendar_string = jscalendar.to_string(); +println!("Generated JSCalendar:\n{}", jscalendar_string); + +let jscontact_string = jscontact.to_string(); +println!("Generated JSContact:\n{}", jscontact_string); +``` + +*Note: Documentation for creating JSCalendar and JSContact objects is coming soon.* + + +## Testing and Fuzzing + +To run the testsuite: + +```bash + $ cargo test --all-features +``` + +or, to run the testsuite with MIRI: + +```bash + $ cargo +nightly miri test --all-features +``` + +To fuzz the library with `cargo-fuzz`: + +```bash + $ cargo +nightly fuzz run fuzz_all + $ cargo +nightly fuzz run fuzz_random + $ cargo +nightly fuzz run fuzz_structured +``` + +## License + +Licensed under either of + + * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +## Funding + +Part of the development of this library was funded through the [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990. + +If you find this library useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you! + +## Copyright + +Copyright (C) 2020, Stalwart Labs LLC diff --git a/vendor/calcard/VENDORED.md b/vendor/calcard/VENDORED.md new file mode 100644 index 00000000..b21b3567 --- /dev/null +++ b/vendor/calcard/VENDORED.md @@ -0,0 +1,57 @@ +# calcard, vendored + +The published [calcard](https://crates.io/crates/calcard) **0.3.14**, taken +from crates.io (`.crate` sha256 +`c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f`, the +checksum the server's `Cargo.lock` had for it) under Apache-2.0 OR MIT. +INBUXA takes it under MIT. The root `Cargo.toml` patches it in with +`[patch.crates-io]`. + +Left out: the top-level `resources/` (26 MB of iCalendar, vCard, +JSCalendar and JSContact samples) and `.github/`, which the server never +builds, and `Cargo.lock`, `Cargo.toml.orig`, `.cargo_vcs_info.json` and +`.cargo-ok`. The crate's own tests that read `resources/` (`expand_rrule`, `parse_ical`, `parse_vcard`, +`convert_jscalendar`, `convert_jscontact` and `remove_component_ids`) can't +run here; the crate's other tests run from this directory with: + + cargo test --offline --target-dir ../../target/calcard-tests -- \ + --skip expand_rrule --skip parse_ical --skip parse_vcard \ + --skip convert_jscalendar --skip convert_jscontact \ + --skip remove_component_ids + +## Changes + +Every changed file carries a Coffey Labs notice in its header. + +- `.gitignore`: `VENDORED.md` is tracked (upstream ignores every `*.md` + but the changelog and README). +- `src/datecalc/mod.rs`, `src/datecalc/rrule_iter.rs`, + `src/datecalc/counter_date.rs`, `src/icalendar/dates.rs`: one + `expand_dates` call has a single work budget for all its recurrence rules + (`MAX_EXPANSION_WORK`, 1,000,000 units, about 50 ms in a release build). + Upstream only stops a rule after 100,000 passes that find nothing, counted + afresh for every instance and every rule, so a calendar of a few thousand + rules that never match (`FREQ=MONTHLY;BYMONTH=2;BYMONTHDAY=31`), or one + sparse rule, took seconds to minutes. A pass over a period costs one unit, + a pass that finds nothing one per 16 days it scanned, each candidate a + pass looks at without producing it one, and each time skipped while + looking for the next `BYHOUR`/`BYMINUTE`/`BYSECOND` match one + (`DateTimeIter::increment_counted` counts those). When the budget runs + out, expansion stops: the instances found so far are kept, as when the + instance limit is reached, later rules aren't expanded, and the result + carries a `CalendarErrorType::ExpansionBudgetExhausted` error for the + component it stopped at. Rules are no longer started once the instance + limit is reached (they couldn't add anything). The `budget_tests` in + `dates.rs` cover it, and `rule_expansion_shares_one_budget` in + `crates/groupware` fails to build if the server stops using this copy. + +## Updating + +When the server's `Cargo.lock` moves calcard to a new version, Cargo stops +using this copy and only warns that the patch went unused, so check +`Cargo.lock` has no `calcard` entry with a `source` line. Re-vendor the new +version from `~/.cargo/registry/src/*/calcard-*`, dropping the top-level +`resources/` and `.github/`, `Cargo.lock`, `Cargo.toml.orig`, +`.cargo_vcs_info.json` and `.cargo-ok` (`rsync --exclude /resources +--exclude /.github ...`), repeat the changes above, and update the version +here and in the root `Cargo.toml` comment. diff --git a/vendor/calcard/src/common/iana.rs b/vendor/calcard/src/common/iana.rs new file mode 100644 index 00000000..904030ec --- /dev/null +++ b/vendor/calcard/src/common/iana.rs @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use std::borrow::Cow; + +use crate::common::{IanaString, IanaType}; + +impl IanaType { + pub fn iana(&self) -> Option<&I> { + match self { + IanaType::Iana(iana) => Some(iana), + _ => None, + } + } + + pub fn other(&self) -> Option<&O> { + match self { + IanaType::Other(s) => Some(s), + _ => None, + } + } + + pub fn into_iana(self) -> Option { + match self { + IanaType::Iana(iana) => Some(iana), + _ => None, + } + } + + pub fn is_iana(&self) -> bool { + matches!(self, IanaType::Iana(_)) + } + + pub fn is_iana_and(&self, f: impl FnOnce(&I) -> bool) -> bool { + match &self { + IanaType::Iana(x) => f(x), + _ => false, + } + } +} + +impl AsRef for IanaType +where + I: IanaString, + O: AsRef, +{ + fn as_ref(&self) -> &str { + match self { + IanaType::Iana(iana) => iana.as_str(), + IanaType::Other(s) => s.as_ref(), + } + } +} + +impl IanaType +where + I: IanaString, +{ + pub fn into_string(self) -> Cow<'static, str> { + match self { + IanaType::Iana(iana) => Cow::Borrowed(iana.as_str()), + IanaType::Other(s) => Cow::Owned(s), + } + } +} + +impl From for IanaType +where + I: IanaString, +{ + fn from(iana: I) -> Self { + IanaType::Iana(iana) + } +} + +impl From for IanaType { + fn from(s: String) -> Self { + IanaType::Other(s) + } +} + +impl Default for IanaType +where + I: IanaString + + std::fmt::Debug + + Clone + + PartialEq + + Eq + + PartialOrd + + Ord + + std::hash::Hash + + Default, + O: std::fmt::Debug + Clone + PartialEq + Eq + PartialOrd + Ord + std::hash::Hash, +{ + fn default() -> Self { + IanaType::Iana(I::default()) + } +} diff --git a/vendor/calcard/src/common/mod.rs b/vendor/calcard/src/common/mod.rs new file mode 100644 index 00000000..6e8a6656 --- /dev/null +++ b/vendor/calcard/src/common/mod.rs @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use chrono::{FixedOffset, NaiveDate, NaiveDateTime}; +use mail_parser::DateTime; + +pub mod iana; +pub mod parser; +pub mod timezone; +pub mod tokenizer; +pub mod types; +pub mod writer; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IanaType { + Iana(I), + Other(O), +} + +pub trait IanaString { + fn as_str(&self) -> &'static str; +} + +pub trait IanaParse: Sized { + fn parse(value: &[u8]) -> Option; +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub struct PartialDateTime { + pub year: Option, + pub month: Option, + pub day: Option, + pub hour: Option, + pub minute: Option, + pub second: Option, + pub tz_hour: Option, + pub tz_minute: Option, + pub tz_minus: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum CalendarScale { + #[default] + Gregorian, + Chinese, + IslamicCivil, + Hebrew, + Ethiopic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Encoding { + QuotedPrintable, + Base64, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub struct Data { + pub content_type: Option, + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DateTimeResult { + pub date_time: NaiveDateTime, + pub offset: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum LinkRelation { + About, + Acl, + Alternate, + Amphtml, + ApiCatalog, + Appendix, + AppleTouchIcon, + AppleTouchStartupImage, + Archives, + Author, + BlockedBy, + Bookmark, + C2paManifest, + Canonical, + Chapter, + CiteAs, + Collection, + CompressionDictionary, + Contents, + Convertedfrom, + Copyright, + CreateForm, + Current, + Deprecation, + Describedby, + Describes, + Disclosure, + DnsPrefetch, + Duplicate, + Edit, + EditForm, + EditMedia, + Enclosure, + External, + First, + Geofeed, + Glossary, + Help, + Hosts, + Hub, + IceServer, + Icon, + Index, + Intervalafter, + Intervalbefore, + Intervalcontains, + Intervaldisjoint, + Intervalduring, + Intervalequals, + Intervalfinishedby, + Intervalfinishes, + Intervalin, + Intervalmeets, + Intervalmetby, + Intervaloverlappedby, + Intervaloverlaps, + Intervalstartedby, + Intervalstarts, + Item, + Last, + LatestVersion, + License, + Linkset, + Lrdd, + Manifest, + MaskIcon, + Me, + MediaFeed, + Memento, + Micropub, + Modulepreload, + Monitor, + MonitorGroup, + Next, + NextArchive, + Nofollow, + Noopener, + Noreferrer, + Opener, + Openid2LocalId, + Openid2Provider, + Original, + P3pv1, + Payment, + Pingback, + Preconnect, + PredecessorVersion, + Prefetch, + Preload, + Prerender, + Prev, + Preview, + Previous, + PrevArchive, + PrivacyPolicy, + Profile, + Publication, + RdapActive, + RdapBottom, + RdapDown, + RdapTop, + RdapUp, + Related, + Restconf, + Replies, + Ruleinput, + Search, + Section, + Self_, + Service, + ServiceDesc, + ServiceDoc, + ServiceMeta, + SipTrunkingCapability, + Sponsored, + Start, + Status, + Stylesheet, + Subsection, + SuccessorVersion, + Sunset, + Tag, + TermsOfService, + Timegate, + Timemap, + Type, + Ugc, + Up, + VersionHistory, + Via, + Webmention, + WorkingCopy, + WorkingCopyOf, +} + +pub(crate) enum IdReference { + Value(T), + Reference(String), + Error, +} + +impl IdReference { + pub fn parse(s: &str) -> Self { + if let Some(reference) = s.strip_prefix('#') { + IdReference::Reference(reference.to_string()) + } else { + T::from_str(s) + .map(IdReference::Value) + .unwrap_or(IdReference::Error) + } + } +} diff --git a/vendor/calcard/src/common/parser.rs b/vendor/calcard/src/common/parser.rs new file mode 100644 index 00000000..9868ea79 --- /dev/null +++ b/vendor/calcard/src/common/parser.rs @@ -0,0 +1,606 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{Data, PartialDateTime, tokenizer::Token}; +use crate::{ + Parser, + common::{IanaParse, IanaString, IanaType}, + icalendar::Uri, +}; +use mail_parser::{ + DateTime, + decoders::{base64::base64_decode, hex::decode_hex}, +}; +use std::{borrow::Cow, iter::Peekable, slice::Iter, str::FromStr}; + +impl<'x> Parser<'x> { + pub(crate) fn raw_token(&mut self) -> Option> { + self.token_buf + .first() + .and_then(|first| { + self.input + .get(first.start..=self.token_buf.last().unwrap().end) + }) + .and_then(|v| std::str::from_utf8(v).ok()) + .map(unfold) + } + + pub(crate) fn buf_parse_many>>(&mut self) -> Vec { + self.token_buf.drain(..).map(T::from).collect() + } + + pub(crate) fn buf_parse_one>>(&mut self) -> Option { + let result = self.token_buf.pop().map(T::from); + self.token_buf.clear(); + result + } +} + +impl Token<'_> { + pub(crate) fn into_uri_bytes(self) -> std::result::Result { + match Data::try_parse(self.text.as_ref()) { + Some(data) => Ok(data), + None => Err(self.into_string()), + } + } + + pub(crate) fn into_timestamp( + self, + require_time: bool, + ) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_timestamp(&mut self.text.iter().peekable(), require_time) { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_offset(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_zone(&mut self.text.iter().peekable()); + if !dt.is_null() { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_float(self) -> std::result::Result { + if let Ok(text) = std::str::from_utf8(self.text.as_ref()) + && let Ok(float) = text.parse::() + { + return Ok(float); + } + + Err(self.into_string()) + } + + pub(crate) fn into_integer(self) -> std::result::Result { + if let Ok(text) = std::str::from_utf8(self.text.as_ref()) + && let Ok(float) = text.parse::() + { + return Ok(float); + } + + Err(self.into_string()) + } + + pub(crate) fn into_boolean(self) -> bool { + self.text.as_ref().eq_ignore_ascii_case(b"true") + } +} + +impl Data { + pub fn try_parse(text: &[u8]) -> Option { + if text + .as_ref() + .get(0..5) + .unwrap_or_default() + .eq_ignore_ascii_case(b"data:") + { + let mut bin = Data::default(); + let text = text.as_ref().get(5..).unwrap_or_default(); + let mut offset_start = 0; + let mut is_base64 = false; + + for (idx, ch) in text.iter().enumerate() { + match ch { + b';' => { + if idx > offset_start && bin.content_type.is_none() { + bin.content_type = Some( + std::str::from_utf8(&text[offset_start..idx]) + .unwrap_or_default() + .to_string(), + ); + } + offset_start = idx + 1; + } + b',' => { + if idx != offset_start { + static B64_LEN: usize = "base64".len(); + + let text = text.get(offset_start..idx).unwrap_or_default(); + if text.len() == B64_LEN && text.eq_ignore_ascii_case(b"base64") + || text.len() == B64_LEN + 1 + && text[..B64_LEN].eq_ignore_ascii_case(b"base64") + { + is_base64 = true; + } else if bin.content_type.is_none() { + bin.content_type = + Some(std::str::from_utf8(text).unwrap_or_default().to_string()); + } + } + + offset_start = idx + 1; + break; + } + _ => {} + } + } + + let text = text.get(offset_start..).unwrap_or_default(); + if !text.is_empty() { + if is_base64 { + if let Some(bytes) = base64_decode(text) { + bin.data = bytes; + return Some(bin); + } + } else { + let (success, bytes) = decode_hex(text); + if success { + bin.data = bytes; + return Some(bin); + } + } + } + } + + None + } +} + +impl PartialDateTime { + pub fn parse_timestamp( + &mut self, + iter: &mut Peekable>, + require_time: bool, + ) -> bool { + let mut idx = 0; + for ch in iter { + match ch { + b'0'..=b'9' => { + let value = match idx { + 0..=3 => { + if let Some(value) = &mut self.year { + *value = + value.saturating_mul(10).saturating_add((ch - b'0') as u16); + } else { + self.year = Some((ch - b'0') as u16); + } + idx += 1; + continue; + } + 4..=5 => &mut self.month, + 6..=7 => &mut self.day, + 9..=10 => &mut self.hour, + 11..=12 => &mut self.minute, + 13..=14 => &mut self.second, + 16..=17 => &mut self.tz_hour, + 18..=19 => &mut self.tz_minute, + _ => return false, + }; + + if let Some(value) = value { + *value = value.saturating_mul(10).saturating_add(ch - b'0'); + } else { + *value = Some(ch - b'0'); + } + } + b'T' | b't' if idx == 8 => {} + b'+' if idx == 15 => {} + b'Z' | b'z' if idx == 15 => { + self.tz_hour = Some(0); + self.tz_minute = Some(0); + } + b'-' if idx == 15 => { + self.tz_minus = true; + } + b' ' | b'\t' | b'\r' | b'\n' => { + continue; + } + _ => break, + } + idx += 1; + } + + if self.hour.is_some() { + self.minute.get_or_insert(0); + self.second.get_or_insert(0); + } + + self.has_date() && (!require_time || self.has_time()) + } + + pub(crate) fn parse_zone(&mut self, iter: &mut Peekable>) -> bool { + self.tz_minus = match iter.peek() { + Some(b'-') => true, + Some(b'+') => false, + Some(b'Z') | Some(b'z') => { + self.tz_hour = Some(0); + self.tz_minute = Some(0); + iter.next(); + return true; + } + _ => return false, + }; + + iter.next(); + let mut idx = 0; + for ch in iter { + match ch { + b'0'..=b'9' => { + idx += 1; + let value = match idx { + 1 | 2 => &mut self.tz_hour, + 3 | 4 => &mut self.tz_minute, + _ => return false, + }; + + if let Some(value) = value { + *value = value.saturating_mul(10).saturating_add(ch - b'0'); + } else { + *value = Some(ch - b'0'); + } + } + _ => { + if !ch.is_ascii_whitespace() { + return false; + } + } + } + } + + self.tz_hour.is_some() + } + + pub fn is_null(&self) -> bool { + self.year.is_none() + && self.month.is_none() + && self.day.is_none() + && self.hour.is_none() + && self.minute.is_none() + && self.second.is_none() + && self.tz_hour.is_none() + && self.tz_minute.is_none() + } + + #[inline(always)] + pub fn has_date(&self) -> bool { + self.year.is_some() && self.month.is_some() && self.day.is_some() + } + + #[inline(always)] + pub fn has_time(&self) -> bool { + self.hour.is_some() && self.minute.is_some() + } + + #[inline(always)] + pub fn has_zone(&self) -> bool { + self.tz_hour.is_some() + } + + #[inline(always)] + pub fn has_date_and_time(&self) -> bool { + self.has_date() && self.has_time() + } + + fn to_datetime(&self) -> Option { + if self.has_date() && self.has_time() { + DateTime { + year: self.year.unwrap(), + month: self.month.unwrap(), + day: self.day.unwrap(), + hour: self.hour.unwrap(), + minute: self.minute.unwrap(), + second: self.second.unwrap_or_default(), + tz_before_gmt: self.tz_minus, + tz_hour: self.tz_hour.unwrap_or_default(), + tz_minute: self.tz_minute.unwrap_or_default(), + } + .into() + } else { + None + } + } + + pub fn to_rfc3339(&self) -> Option { + self.to_datetime().map(|dt| dt.to_rfc3339()) + } + + pub fn to_timestamp(&self) -> Option { + self.to_datetime().map(|dt| dt.to_timestamp()) + } +} + +pub(crate) fn parse_digits( + iter: &mut Peekable>, + target: &mut Option, + num: usize, + nullable: bool, +) -> bool { + let mut idx = 0; + while let Some(ch) = iter.peek() { + match ch { + b'0'..=b'9' => { + let ch = (*ch - b'0') as u16; + idx += 1; + iter.next(); + + if let Some(target) = target { + *target = target.saturating_mul(10).saturating_add(ch); + + if idx == num { + return true; + } + } else { + *target = Some(ch); + } + } + b'-' if nullable => { + idx += 1; + iter.next(); + if idx == num / 2 { + return true; + } + } + _ => { + if !ch.is_ascii_whitespace() { + return false; + } else { + iter.next(); + } + } + }; + } + + false +} + +pub(crate) fn parse_small_digits( + iter: &mut Peekable>, + target: &mut Option, + num: usize, + nullable: bool, +) -> bool { + let mut idx = 0; + while let Some(ch) = iter.peek() { + match ch { + b'0'..=b'9' => { + let ch = *ch - b'0'; + idx += 1; + iter.next(); + + if let Some(target) = target { + *target = target.saturating_mul(10).saturating_add(ch); + + if idx == num { + return true; + } + } else { + *target = Some(ch); + } + } + b'-' if nullable => { + idx += 1; + iter.next(); + if idx == num / 2 { + return true; + } + } + _ => { + if !ch.is_ascii_whitespace() { + return false; + } else { + iter.next(); + } + } + }; + } + + false +} + +pub(crate) fn unfold(text: &str) -> Cow<'_, str> { + if !text.as_bytes().contains(&b'\n') { + return Cow::Borrowed(text); + } + + let mut unfolded = String::with_capacity(text.len()); + + for (pos, chunk) in text.split('\n').enumerate() { + let chunk = if pos == 0 { + chunk + } else { + chunk.strip_prefix([' ', '\t']).unwrap_or(chunk) + }; + unfolded.push_str(chunk.strip_suffix('\r').unwrap_or(chunk)); + } + + Cow::Owned(unfolded) +} + +#[derive(Default)] +pub(crate) struct Timestamp(pub i64); + +impl FromStr for Timestamp { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_timestamp(&mut s.as_bytes().iter().peekable(), true); + dt.to_timestamp().map(Timestamp).ok_or(()) + } +} + +impl IanaParse for Timestamp { + fn parse(value: &[u8]) -> Option { + let mut dt = PartialDateTime::default(); + dt.parse_timestamp(&mut value.iter().peekable(), true); + dt.to_timestamp().map(Timestamp) + } +} + +pub(crate) struct Integer(pub i64); + +impl IanaParse for Integer { + fn parse(value: &[u8]) -> Option { + let mut num: i64 = 0; + let mut is_negative = false; + + for (pos, ch) in value.iter().enumerate() { + match ch { + b'-' if pos == 0 => { + is_negative = true; + } + b'+' if pos == 0 => {} + b'0'..=b'9' => { + num = num.saturating_mul(10).saturating_add((*ch - b'0') as i64); + } + _ => { + if !ch.is_ascii_whitespace() { + return None; + } + } + } + } + + Some(Integer(if is_negative { -num } else { num })) + } +} + +pub(crate) struct Boolean(pub bool); + +impl IanaParse for Boolean { + fn parse(value: &[u8]) -> Option { + if value.eq_ignore_ascii_case(b"true") { + Some(Boolean(true)) + } else if value.eq_ignore_ascii_case(b"false") { + Some(Boolean(false)) + } else { + None + } + } +} + +impl From> for IanaType +where + I: IanaParse, +{ + fn from(value: Token<'_>) -> Self { + I::parse(value.text.as_ref()) + .map(IanaType::Iana) + .unwrap_or_else(|| IanaType::Other(value.into_string())) + } +} + +impl From> for IanaType +where + I: IanaString + + IanaParse + + std::fmt::Debug + + Clone + + PartialEq + + Eq + + PartialOrd + + Ord + + std::hash::Hash, +{ + fn from(value: Token<'_>) -> Self { + I::parse(value.text.as_ref()) + .map(IanaType::Iana) + .unwrap_or_else(|| { + IanaType::Other( + value + .into_uri_bytes() + .map(Uri::Data) + .unwrap_or_else(Uri::Location), + ) + }) + } +} + +#[cfg(test)] +mod tests { + + use crate::common::tokenizer::StopChar; + + use super::*; + + #[test] + fn test_parse_uri() { + for (uri, expected) in [ + ( + concat!( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAQAAAADpb+\n", + "tAAAAQklEQVQI122PQQ4AMAjCKv//Mzs4M0zmRYKkamEwWQVoRJogk4PuRoOoMC/EK8nYb+", + "l08WGvSxKlNHO5kxnp/WXrAzsSERN1N6q5AAAAAElFTkSuQmCC" + ), + Data { + content_type: Some("image/png".to_string()), + data: vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 11, + 0, 0, 0, 11, 8, 4, 0, 0, 0, 3, 165, 191, 173, 0, 0, 0, 66, 73, 68, 65, 84, + 8, 215, 109, 143, 65, 14, 0, 48, 8, 194, 42, 255, 255, 51, 59, 56, 51, 76, + 230, 69, 130, 164, 106, 97, 48, 89, 5, 104, 68, 154, 32, 147, 131, 238, 70, + 131, 168, 48, 47, 196, 43, 201, 216, 111, 233, 116, 241, 97, 175, 75, 18, + 165, 52, 115, 185, 147, 25, 233, 253, 101, 235, 3, 59, 18, 17, 19, 117, 55, + 170, 185, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ], + }, + ), + ( + concat!( + "data:text/html,%3Cbody%3E%3Cp%3EToller%20%3Cstron", + "g%20class%3D%22text-bold%22%3ETermin%3C%2Fstrong%3E%20f%C3%BCr%3C%2Fp", + "%3E%3Cblockquote%3E%3Cp%3Emal%20%3Cem%20class%3D%22text-italic%22%3Ez", + "u%22gucken%22%3C%2Fem%3E%3C%2Fp%3E%3C%2Fblockquote%3E%3Cp%3E%3Cu%20cl", + "ass%3D%22text-underline%22%3Eund%3C%2Fu%3E%20%3Cdel%3Eso%3C%2Fdel%3E%", + "3Cbr%2F%3E%3C%2Fp%3E%3C%2Fbody%3E" + ), + Data { + content_type: Some("text/html".to_string()), + data: vec![ + 60, 98, 111, 100, 121, 62, 60, 112, 62, 84, 111, 108, 108, 101, 114, 32, + 60, 115, 116, 114, 111, 110, 103, 32, 99, 108, 97, 115, 115, 61, 34, 116, + 101, 120, 116, 45, 98, 111, 108, 100, 34, 62, 84, 101, 114, 109, 105, 110, + 60, 47, 115, 116, 114, 111, 110, 103, 62, 32, 102, 195, 188, 114, 60, 47, + 112, 62, 60, 98, 108, 111, 99, 107, 113, 117, 111, 116, 101, 62, 60, 112, + 62, 109, 97, 108, 32, 60, 101, 109, 32, 99, 108, 97, 115, 115, 61, 34, 116, + 101, 120, 116, 45, 105, 116, 97, 108, 105, 99, 34, 62, 122, 117, 34, 103, + 117, 99, 107, 101, 110, 34, 60, 47, 101, 109, 62, 60, 47, 112, 62, 60, 47, + 98, 108, 111, 99, 107, 113, 117, 111, 116, 101, 62, 60, 112, 62, 60, 117, + 32, 99, 108, 97, 115, 115, 61, 34, 116, 101, 120, 116, 45, 117, 110, 100, + 101, 114, 108, 105, 110, 101, 34, 62, 117, 110, 100, 60, 47, 117, 62, 32, + 60, 100, 101, 108, 62, 115, 111, 60, 47, 100, 101, 108, 62, 60, 98, 114, + 47, 62, 60, 47, 112, 62, 60, 47, 98, 111, 100, 121, 62, + ], + }, + ), + ] { + assert_eq!( + Token { + text: uri.as_bytes().into(), + start: 0, + end: 0, + stop_char: StopChar::Lf + } + .into_uri_bytes(), + Ok(expected) + ); + } + } +} diff --git a/vendor/calcard/src/common/timezone.rs b/vendor/calcard/src/common/timezone.rs new file mode 100644 index 00000000..cf7bebca --- /dev/null +++ b/vendor/calcard/src/common/timezone.rs @@ -0,0 +1,2046 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::DateTimeResult; +use crate::common::PartialDateTime; +use chrono::{DateTime, FixedOffset, NaiveDate, Offset, TimeZone, Utc}; +use hashify::tiny_map; +use std::{borrow::Cow, hash::Hash, str::FromStr}; + +#[derive(Clone, Copy, Default, Eq)] +pub enum Tz { + #[default] + Floating, + Fixed(FixedOffset), + Tz(chrono_tz::Tz), +} + +pub trait TzTimestamp { + fn to_naive_timestamp(&self) -> i64; +} + +impl PartialDateTime { + pub fn to_date_time_with_tz(&self, tz: Tz) -> Option> { + self.to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(tz)) + } +} + +impl DateTimeResult { + pub fn tz(&self) -> Option { + self.offset.map(|offset| { + if offset.local_minus_utc() == 0 { + Tz::UTC + } else { + Tz::Fixed(offset) + } + }) + } + + pub fn to_date_time_with_tz(&self, tz: Tz) -> Option> { + if let Some(offset) = self.offset { + if offset.local_minus_utc() == 0 { + Tz::UTC.from_utc_datetime(&self.date_time).into() + } else { + Tz::Fixed(offset) + .from_local_datetime(&self.date_time) + .single() + } + } else { + tz.from_local_datetime(&self.date_time).single() + } + } +} + +impl Tz { + pub const UTC: Self = Self::Tz(chrono_tz::UTC); + + pub fn as_id(&self) -> u16 { + match self { + Tz::Tz(tz) => chrono_tz_to_id(tz), + Tz::Fixed(offset) => { + let offset = offset.local_minus_utc() / 60; + debug_assert!((-1440..=1440).contains(&offset) && offset != 0); + 0x8000 | ((offset + 1440) as u16) + } + Tz::Floating => 0x8000, + } + } + + pub fn from_id(id: u16) -> Option { + if id & 0x8000 == 0 { + id_to_chrono_tz(id).map(Self::Tz) + } else if id == 0x8000 { + Some(Self::Floating) + } else { + let offset = (id & 0x7FFF) as i16 - 1440; + debug_assert!((-1440..=1440).contains(&offset)); + FixedOffset::east_opt(offset as i32 * 60).map(Self::Fixed) + } + } + + pub fn name(&self) -> Option> { + match self { + Self::Tz(chrono_tz::Tz::UTC) => Some(Cow::Borrowed("Etc/UTC")), + Self::Tz(tz) => Some(Cow::Borrowed(tz.name())), + Self::Fixed(offset) => { + let hour = offset.local_minus_utc() / 3600; + Some(if hour != 0 { + Cow::Owned(format!( + "Etc/GMT{}{}", + if hour < 0 { "+" } else { "-" }, + hour.abs() + )) + } else { + Cow::Borrowed("Etc/UTC") + }) + } + Self::Floating => None, + } + } + + #[inline] + pub fn to_resolved(&self) -> Option { + match self { + Tz::Floating => None, + _ => Some(*self), + } + } + + #[inline] + pub fn is_floating(&self) -> bool { + matches!(self, Self::Floating) + } + + pub fn is_utc(&self) -> bool { + match self { + Self::Tz(chrono_tz::UTC | chrono_tz::Tz::Etc__GMT | chrono_tz::Tz::Etc__UTC) => true, + Self::Fixed(offset) => offset.local_minus_utc() == 0, + _ => false, + } + } + + pub fn offset_from_utc_date(&self, utc: &NaiveDate) -> i32 { + match self { + Tz::Floating => 0, + Tz::Fixed(offset) => offset.local_minus_utc(), + Tz::Tz(tz) => tz.offset_from_utc_date(utc).fix().local_minus_utc(), + } + } + + pub fn offset_parts(&self) -> PartialDateTime { + let mut seconds = self.offset_from_utc_date(&Utc::now().date_naive()); + + let tz_minus = if seconds < 0 { + seconds = -seconds; + true + } else { + false + }; + + PartialDateTime { + tz_hour: Some((seconds / 3600) as u8), + tz_minute: Some(((seconds % 3600) / 60) as u8), + tz_minus, + ..Default::default() + } + } + + pub fn from_ms_cdo_zone_id(id: &str) -> Option { + // Source https://learn.microsoft.com/en-us/previous-versions/office/developer/exchange-server-2007/aa563018(v=exchg.80) + + tiny_map!(id.as_bytes(), + "0" => chrono_tz::Tz::UTC, + "1" => chrono_tz::Tz::Europe__London, + "10" => chrono_tz::Tz::America__New_York, + "11" => chrono_tz::Tz::America__Chicago, + "12" => chrono_tz::Tz::America__Denver, + "13" => chrono_tz::Tz::America__Los_Angeles, + "14" => chrono_tz::Tz::America__Anchorage, + "15" => chrono_tz::Tz::Pacific__Honolulu, + "16" => chrono_tz::Tz::Pacific__Midway, + "17" => chrono_tz::Tz::Pacific__Auckland, + "18" => chrono_tz::Tz::Australia__Brisbane, + "19" => chrono_tz::Tz::Australia__Adelaide, + "2" => chrono_tz::Tz::Europe__Lisbon, + "20" => chrono_tz::Tz::Asia__Tokyo, + "21" => chrono_tz::Tz::Asia__Singapore, + "22" => chrono_tz::Tz::Asia__Bangkok, + "23" => chrono_tz::Tz::Asia__Calcutta, + "24" => chrono_tz::Tz::Asia__Muscat, + "25" => chrono_tz::Tz::Asia__Tehran, + "26" => chrono_tz::Tz::Asia__Baghdad, + "27" => chrono_tz::Tz::Asia__Jerusalem, + "28" => chrono_tz::Tz::America__St_Johns, + "29" => chrono_tz::Tz::Atlantic__Azores, + "3" => chrono_tz::Tz::Europe__Paris, + "30" => chrono_tz::Tz::America__Noronha, + "31" => chrono_tz::Tz::Africa__Casablanca, + "32" => chrono_tz::Tz::America__Argentina__Buenos_Aires, + "33" => chrono_tz::Tz::America__Caracas, + "34" => chrono_tz::Tz::America__Indiana__Indianapolis, + "35" => chrono_tz::Tz::America__Bogota, + "36" => chrono_tz::Tz::America__Edmonton, + "37" => chrono_tz::Tz::America__Mexico_City, + "38" => chrono_tz::Tz::America__Phoenix, + "39" => chrono_tz::Tz::Pacific__Kwajalein, + "4" => chrono_tz::Tz::Europe__Berlin, + "40" => chrono_tz::Tz::Pacific__Fiji, + "41" => chrono_tz::Tz::Asia__Magadan, + "42" => chrono_tz::Tz::Australia__Hobart, + "43" => chrono_tz::Tz::Pacific__Guam, + "44" => chrono_tz::Tz::Australia__Darwin, + "45" => chrono_tz::Tz::Asia__Shanghai, + "46" => chrono_tz::Tz::Asia__Almaty, + "47" => chrono_tz::Tz::Asia__Karachi, + "48" => chrono_tz::Tz::Asia__Kabul, + "49" => chrono_tz::Tz::Africa__Cairo, + "5" => chrono_tz::Tz::Europe__Bucharest, + "50" => chrono_tz::Tz::Africa__Harare, + "51" => chrono_tz::Tz::Europe__Moscow, + "53" => chrono_tz::Tz::Atlantic__Cape_Verde, + "54" => chrono_tz::Tz::Asia__Baku, + "55" => chrono_tz::Tz::America__Guatemala, + "56" => chrono_tz::Tz::Africa__Nairobi, + "58" => chrono_tz::Tz::Asia__Yekaterinburg, + "59" => chrono_tz::Tz::Europe__Helsinki, + "6" => chrono_tz::Tz::Europe__Prague, + "60" => chrono_tz::Tz::America__Godthab, + "61" => chrono_tz::Tz::Asia__Rangoon, + "62" => chrono_tz::Tz::Asia__Kathmandu, + "63" => chrono_tz::Tz::Asia__Irkutsk, + "64" => chrono_tz::Tz::Asia__Krasnoyarsk, + "65" => chrono_tz::Tz::America__Santiago, + "66" => chrono_tz::Tz::Asia__Colombo, + "67" => chrono_tz::Tz::Pacific__Tongatapu, + "68" => chrono_tz::Tz::Asia__Vladivostok, + "69" => chrono_tz::Tz::Africa__Luanda, + "7" => chrono_tz::Tz::Europe__Athens, + "70" => chrono_tz::Tz::Asia__Yakutsk, + "71" => chrono_tz::Tz::Asia__Dhaka, + "72" => chrono_tz::Tz::Asia__Seoul, + "73" => chrono_tz::Tz::Australia__Perth, + "74" => chrono_tz::Tz::Asia__Kuwait, + "75" => chrono_tz::Tz::Asia__Taipei, + "76" => chrono_tz::Tz::Australia__Sydney, + "8" => chrono_tz::Tz::America__Sao_Paulo, + "9" => chrono_tz::Tz::America__Halifax, + ) + .map(Self::Tz) + } +} + +impl TzTimestamp for DateTime { + fn to_naive_timestamp(&self) -> i64 { + self.naive_local().and_utc().timestamp() + } +} + +impl FromStr for Tz { + type Err = (); + + /* + Calconnect recommends ignoring VTIMEZONE and just "guess" the timezone + + See: https://standards.calconnect.org/cc/cc-r0602-2006.html + + */ + + fn from_str(s: &str) -> Result { + // First try with the chrono_tz::Tz + if let Ok(tz) = chrono_tz::Tz::from_str(s) { + return Ok(Self::Tz(tz)); + } + + // Strip common prefixes + let mut s = s.trim(); + let mut zone_offset = None; + let mut retry_chrono_tz = false; + if let Some(part) = s.strip_prefix('(') { + if let Some((zone, name)) = part.split_once(')') { + s = name.trim_start(); + zone_offset = Some(zone.trim()); + } + } else if let Some(mut name) = s.strip_prefix('/') { + /* + The presence of the SOLIDUS character as a prefix, indicates that + this "TZID" represents a unique ID in a globally defined time zone + registry (when such registry is defined). + */ + if name.as_bytes().iter().filter(|&&c| c == b'/').count() > 2 { + // Extract zones such as '/softwarestudio.org/Olson_20011030_5/America/Chicago' + if let Some(new_name) = name.splitn(3, '/').nth(2) { + name = new_name.strip_prefix("SystemV/").unwrap_or(new_name); + } + } + retry_chrono_tz = true; + s = name; + } + + // Try again with chrono_tz::Tz + if retry_chrono_tz && let Ok(tz) = chrono_tz::Tz::from_str(s) { + return Ok(Self::Tz(tz)); + } + + // Map proprietary timezones to chrono_tz::Tz + let result = hashify::map!(s.as_bytes(), chrono_tz::Tz, + "AUS Central Standard Time" => chrono_tz::Tz::Australia__Darwin, + "AUS Central" => chrono_tz::Tz::Australia__Darwin, + "AUS Eastern Standard Time" => chrono_tz::Tz::Australia__Sydney, + "AUS Eastern" => chrono_tz::Tz::Australia__Sydney, + "Abu Dhabi, Muscat" => chrono_tz::Tz::Asia__Muscat, + "Adelaide, Central Australia" => chrono_tz::Tz::Australia__Adelaide, + "Afghanistan Standard Time" => chrono_tz::Tz::Asia__Kabul, + "Afghanistan" => chrono_tz::Tz::Asia__Kabul, + "Alaska" => chrono_tz::Tz::America__Anchorage, + "Alaskan Standard Time" => chrono_tz::Tz::America__Anchorage, + "Alaskan" => chrono_tz::Tz::America__Anchorage, + "Aleutian Standard Time" => chrono_tz::Tz::America__Adak, + "Almaty, Novosibirsk, North Central Asia" => chrono_tz::Tz::Asia__Almaty, + "Altai Standard Time" => chrono_tz::Tz::Asia__Barnaul, + "Amsterdam, Berlin, Bern, Rom, Stockholm, Wien" => chrono_tz::Tz::Europe__Berlin, + "Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna" => chrono_tz::Tz::Europe__Berlin, + "Arab Standard Time" => chrono_tz::Tz::Asia__Riyadh, + "Arab" => chrono_tz::Tz::Asia__Kuwait, + "Arab, Kuwait, Riyadh" => chrono_tz::Tz::Asia__Kuwait, + "Arabian Standard Time" => chrono_tz::Tz::Asia__Dubai, + "Arabian" => chrono_tz::Tz::Asia__Muscat, + "Arabic Standard Time" => chrono_tz::Tz::Asia__Baghdad, + "Arabic" => chrono_tz::Tz::Asia__Baghdad, + "Argentina Standard Time" => chrono_tz::Tz::America__Buenos_Aires, + "Argentina" => chrono_tz::Tz::America__Argentina__Buenos_Aires, + "Arizona" => chrono_tz::Tz::America__Phoenix, + "Armenian" => chrono_tz::Tz::Asia__Yerevan, + "Astana, Dhaka" => chrono_tz::Tz::Asia__Dhaka, + "Astrakhan Standard Time" => chrono_tz::Tz::Europe__Astrakhan, + "Athens, Istanbul, Minsk" => chrono_tz::Tz::Europe__Athens, + "Atlantic Standard Time" => chrono_tz::Tz::America__Halifax, + "Atlantic Time (Canada)" => chrono_tz::Tz::America__Halifax, + "Atlantic" => chrono_tz::Tz::America__Halifax, + "Auckland, Wellington" => chrono_tz::Tz::Pacific__Auckland, + "Aus Central W. Standard Time" => chrono_tz::Tz::Australia__Eucla, + "Azerbaijan Standard Time" => chrono_tz::Tz::Asia__Baku, + "Azerbijan" => chrono_tz::Tz::Asia__Baku, + "Azores Standard Time" => chrono_tz::Tz::Atlantic__Azores, + "Azores" => chrono_tz::Tz::Atlantic__Azores, + "Baghdad" => chrono_tz::Tz::Asia__Baghdad, + "Bahia Standard Time" => chrono_tz::Tz::America__Bahia, + "Baku, Tbilisi, Yerevan" => chrono_tz::Tz::Asia__Baku, + "Bangkok, Hanoi, Jakarta" => chrono_tz::Tz::Asia__Bangkok, + "Bangladesh Standard Time" => chrono_tz::Tz::Asia__Dhaka, + "Beijing, Chongqing, Hong Kong SAR, Urumqi" => chrono_tz::Tz::Asia__Shanghai, + "Belarus Standard Time" => chrono_tz::Tz::Europe__Minsk, + "Belgrade, Pozsony, Budapest, Ljubljana, Prague" => chrono_tz::Tz::Europe__Prague, + "Bogota, Lima, Quito" => chrono_tz::Tz::America__Bogota, + "Bougainville Standard Time" => chrono_tz::Tz::Pacific__Bougainville, + "Brasilia" => chrono_tz::Tz::America__Sao_Paulo, + "Brisbane, East Australia" => chrono_tz::Tz::Australia__Brisbane, + "Brussels, Copenhagen, Madrid, Paris" => chrono_tz::Tz::Europe__Paris, + "Bucharest" => chrono_tz::Tz::Europe__Bucharest, + "Buenos Aires" => chrono_tz::Tz::America__Argentina__Buenos_Aires, + "Cairo" => chrono_tz::Tz::Africa__Cairo, + "Canada Central Standard Time" => chrono_tz::Tz::America__Regina, + "Canada Central" => chrono_tz::Tz::America__Edmonton, + "Canberra, Melbourne, Sydney, Hobart" => chrono_tz::Tz::Australia__Sydney, + "Canberra, Melbourne, Sydney" => chrono_tz::Tz::Australia__Sydney, + "Cape Verde Is." => chrono_tz::Tz::Atlantic__Cape_Verde, + "Cape Verde Standard Time" => chrono_tz::Tz::Atlantic__Cape_Verde, + "Cape Verde" => chrono_tz::Tz::Atlantic__Cape_Verde, + "Caracas, La Paz" => chrono_tz::Tz::America__Caracas, + "Casablanca, Monrovia" => chrono_tz::Tz::Africa__Casablanca, + "Caucasus Standard Time" => chrono_tz::Tz::Asia__Yerevan, + "Caucasus" => chrono_tz::Tz::Asia__Yerevan, + "Cen. Australia Standard Time" => chrono_tz::Tz::Australia__Adelaide, + "Cen. Australia" => chrono_tz::Tz::Australia__Adelaide, + "Central America Standard Time" => chrono_tz::Tz::America__Guatemala, + "Central America" => chrono_tz::Tz::America__Guatemala, + "Central Asia Standard Time" => chrono_tz::Tz::Asia__Almaty, + "Central Asia" => chrono_tz::Tz::Asia__Dhaka, + "Central Brazilian Standard Time" => chrono_tz::Tz::America__Cuiaba, + "Central Brazilian" => chrono_tz::Tz::America__Manaus, + "Central Europe Standard Time" => chrono_tz::Tz::Europe__Budapest, + "Central Europe" => chrono_tz::Tz::Europe__Prague, + "Central European Standard Time" => chrono_tz::Tz::Europe__Warsaw, + "Central European" => chrono_tz::Tz::Europe__Sarajevo, + "Central Pacific Standard Time" => chrono_tz::Tz::Pacific__Guadalcanal, + "Central Pacific" => chrono_tz::Tz::Asia__Magadan, + "Central Standard Time (Mexico)" => chrono_tz::Tz::America__Mexico_City, + "Central Standard Time" => chrono_tz::Tz::America__Chicago, + "Central Time (US & Canada)" => chrono_tz::Tz::America__Chicago, + "Central Time (US and Canada)" => chrono_tz::Tz::America__Chicago, + "Central" => chrono_tz::Tz::America__Chicago, + "Chatham Islands Standard Time" => chrono_tz::Tz::Pacific__Chatham, + "China Standard Time" => chrono_tz::Tz::Asia__Shanghai, + "China" => chrono_tz::Tz::Asia__Shanghai, + "Cuba Standard Time" => chrono_tz::Tz::America__Havana, + "Darwin" => chrono_tz::Tz::Australia__Darwin, + "Dateline Standard Time" => chrono_tz::Tz::Etc__GMTPlus12, + "Dateline" => chrono_tz::Tz::Etc__GMTMinus12, + "E. Africa Standard Time" => chrono_tz::Tz::Africa__Nairobi, + "E. Africa" => chrono_tz::Tz::Africa__Nairobi, + "E. Australia Standard Time" => chrono_tz::Tz::Australia__Brisbane, + "E. Australia" => chrono_tz::Tz::Australia__Brisbane, + "E. Europe Standard Time" => chrono_tz::Tz::Europe__Chisinau, + "E. Europe" => chrono_tz::Tz::Europe__Minsk, + "E. South America Standard Time" => chrono_tz::Tz::America__Sao_Paulo, + "E. South America" => chrono_tz::Tz::America__Belem, + "East Africa, Nairobi" => chrono_tz::Tz::Africa__Nairobi, + "Easter Island Standard Time" => chrono_tz::Tz::Pacific__Easter, + "Eastern Standard Time (Mexico)" => chrono_tz::Tz::America__Cancun, + "Eastern Standard Time" => chrono_tz::Tz::America__New_York, + "Eastern Time (US & Canada)" => chrono_tz::Tz::America__New_York, + "Eastern Time (US and Canada)" => chrono_tz::Tz::America__New_York, + "Eastern" => chrono_tz::Tz::America__New_York, + "Egypt Standard Time" => chrono_tz::Tz::Africa__Cairo, + "Egypt" => chrono_tz::Tz::Africa__Cairo, + "Ekaterinburg Standard Time" => chrono_tz::Tz::Asia__Yekaterinburg, + "Ekaterinburg" => chrono_tz::Tz::Asia__Yekaterinburg, + "Eniwetok, Kwajalein, Dateline Time" => chrono_tz::Tz::Pacific__Kwajalein, + "FLE Standard Time" => chrono_tz::Tz::Europe__Kiev, + "FLE" => chrono_tz::Tz::Europe__Helsinki, + "Fiji Islands, Kamchatka, Marshall Is." => chrono_tz::Tz::Pacific__Fiji, + "Fiji Standard Time" => chrono_tz::Tz::Pacific__Fiji, + "Fiji" => chrono_tz::Tz::Pacific__Fiji, + "GMT Standard Time" => chrono_tz::Tz::Europe__London, + "GTB Standard Time" => chrono_tz::Tz::Europe__Bucharest, + "GTB" => chrono_tz::Tz::Europe__Athens, + "Georgian Standard Time" => chrono_tz::Tz::Asia__Tbilisi, + "Georgian" => chrono_tz::Tz::Asia__Tbilisi, + "Greenland Standard Time" => chrono_tz::Tz::America__Godthab, + "Greenland" => chrono_tz::Tz::America__Godthab, + "Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London" => chrono_tz::Tz::Europe__Lisbon, + "Greenwich Mean Time; Dublin, Edinburgh, London" => chrono_tz::Tz::Europe__London, + "Greenwich Standard Time" => chrono_tz::Tz::Atlantic__Reykjavik, + "Greenwich" => chrono_tz::Tz::Atlantic__Reykjavik, + "Guam, Port Moresby" => chrono_tz::Tz::Pacific__Guam, + "Haiti Standard Time" => chrono_tz::Tz::America__PortauPrince, + "Harare, Pretoria" => chrono_tz::Tz::Africa__Harare, + "Hawaii" => chrono_tz::Tz::Pacific__Honolulu, + "Hawaiian Standard Time" => chrono_tz::Tz::Pacific__Honolulu, + "Hawaiian" => chrono_tz::Tz::Pacific__Honolulu, + "Helsinki, Riga, Tallinn" => chrono_tz::Tz::Europe__Helsinki, + "Hobart, Tasmania" => chrono_tz::Tz::Australia__Hobart, + "India Standard Time" => chrono_tz::Tz::Asia__Calcutta, + "India" => chrono_tz::Tz::Asia__Calcutta, + "Indiana (East)" => chrono_tz::Tz::America__Indiana__Indianapolis, + "Iran Standard Time" => chrono_tz::Tz::Asia__Tehran, + "Iran" => chrono_tz::Tz::Asia__Tehran, + "Irkutsk, Ulaan Bataar" => chrono_tz::Tz::Asia__Irkutsk, + "Islamabad, Karachi, Tashkent" => chrono_tz::Tz::Asia__Karachi, + "Israel Standard Time" => chrono_tz::Tz::Asia__Jerusalem, + "Israel" => chrono_tz::Tz::Asia__Jerusalem, + "Israel, Jerusalem Standard Time" => chrono_tz::Tz::Asia__Jerusalem, + "Jordan Standard Time" => chrono_tz::Tz::Asia__Amman, + "Jordan" => chrono_tz::Tz::Asia__Amman, + "Kabul" => chrono_tz::Tz::Asia__Kabul, + "Kaliningrad Standard Time" => chrono_tz::Tz::Europe__Kaliningrad, + "Kathmandu, Nepal" => chrono_tz::Tz::Asia__Kathmandu, + "Kolkata, Chennai, Mumbai, New Delhi, India Standard Time" => chrono_tz::Tz::Asia__Calcutta, + "Korea Standard Time" => chrono_tz::Tz::Asia__Seoul, + "Korea" => chrono_tz::Tz::Asia__Seoul, + "Krasnoyarsk" => chrono_tz::Tz::Asia__Krasnoyarsk, + "Kuala Lumpur, Singapore" => chrono_tz::Tz::Asia__Singapore, + "Libya Standard Time" => chrono_tz::Tz::Africa__Tripoli, + "Line Islands Standard Time" => chrono_tz::Tz::Pacific__Kiritimati, + "Lord Howe Standard Time" => chrono_tz::Tz::Australia__Lord_Howe, + "Magadan Standard Time" => chrono_tz::Tz::Asia__Magadan, + "Magadan, Solomon Is., New Caledonia" => chrono_tz::Tz::Asia__Magadan, + "Magallanes Standard Time" => chrono_tz::Tz::America__Punta_Arenas, + "Marquesas Standard Time" => chrono_tz::Tz::Pacific__Marquesas, + "Mauritius Standard Time" => chrono_tz::Tz::Indian__Mauritius, + "Mauritius" => chrono_tz::Tz::Indian__Mauritius, + "Mexico City, Tegucigalpa" => chrono_tz::Tz::America__Mexico_City, + "Mexico Standard Time 2" => chrono_tz::Tz::America__Chihuahua, + "Mexico" => chrono_tz::Tz::America__Mexico_City, + "Mid-Atlantic" => chrono_tz::Tz::America__Noronha, + "Middle East Standard Time" => chrono_tz::Tz::Asia__Beirut, + "Middle East" => chrono_tz::Tz::Asia__Beirut, + "Midway Island, Samoa" => chrono_tz::Tz::Pacific__Midway, + "Montevideo Standard Time" => chrono_tz::Tz::America__Montevideo, + "Montevideo" => chrono_tz::Tz::America__Montevideo, + "Morocco Standard Time" => chrono_tz::Tz::Africa__Casablanca, + "Morocco" => chrono_tz::Tz::Africa__Casablanca, + "Moscow, St. Petersburg, Volgograd" => chrono_tz::Tz::Europe__Moscow, + "Mountain Standard Time (Mexico)" => chrono_tz::Tz::America__Chihuahua, + "Mountain Standard Time" => chrono_tz::Tz::America__Denver, + "Mountain Time (US & Canada)" => chrono_tz::Tz::America__Denver, + "Mountain Time (US and Canada)" => chrono_tz::Tz::America__Denver, + "Mountain" => chrono_tz::Tz::America__Denver, + "Myanmar Standard Time" => chrono_tz::Tz::Asia__Rangoon, + "Myanmar" => chrono_tz::Tz::Asia__Rangoon, + "N. Central Asia Standard Time" => chrono_tz::Tz::Asia__Novosibirsk, + "N. Central Asia" => chrono_tz::Tz::Asia__Almaty, + "Namibia Standard Time" => chrono_tz::Tz::Africa__Windhoek, + "Namibia" => chrono_tz::Tz::Africa__Windhoek, + "Nepal Standard Time" => chrono_tz::Tz::Asia__Katmandu, + "Nepal" => chrono_tz::Tz::Asia__Kathmandu, + "New Zealand Standard Time" => chrono_tz::Tz::Pacific__Auckland, + "New Zealand" => chrono_tz::Tz::Pacific__Auckland, + "Newfoundland Standard Time" => chrono_tz::Tz::America__St_Johns, + "Newfoundland" => chrono_tz::Tz::America__St_Johns, + "Norfolk Standard Time" => chrono_tz::Tz::Pacific__Norfolk, + "North Asia East Standard Time" => chrono_tz::Tz::Asia__Irkutsk, + "North Asia East" => chrono_tz::Tz::Asia__Irkutsk, + "North Asia Standard Time" => chrono_tz::Tz::Asia__Krasnoyarsk, + "North Asia" => chrono_tz::Tz::Asia__Krasnoyarsk, + "North Korea Standard Time" => chrono_tz::Tz::Asia__Pyongyang, + "Omsk Standard Time" => chrono_tz::Tz::Asia__Omsk, + "Osaka, Sapporo, Tokyo" => chrono_tz::Tz::Asia__Tokyo, + "Japan" => chrono_tz::Tz::Asia__Tokyo, + "Pacific SA Standard Time" => chrono_tz::Tz::America__Santiago, + "Pacific SA" => chrono_tz::Tz::America__Santiago, + "Pacific Standard Time (Mexico)" => chrono_tz::Tz::America__Tijuana, + "Pacific Standard Time" => chrono_tz::Tz::America__Los_Angeles, + "Pacific Time (US & Canada)" => chrono_tz::Tz::America__Los_Angeles, + "Pacific Time (US & Canada); Tijuana" => chrono_tz::Tz::America__Los_Angeles, + "Pacific Time (US and Canada)" => chrono_tz::Tz::America__Los_Angeles, + "Pacific Time (US and Canada); Tijuana" => chrono_tz::Tz::America__Los_Angeles, + "Pacific" => chrono_tz::Tz::America__Los_Angeles, + "Pakistan Standard Time" => chrono_tz::Tz::Asia__Karachi, + "Pakistan" => chrono_tz::Tz::Asia__Karachi, + "Paraguay Standard Time" => chrono_tz::Tz::America__Asuncion, + "Paris, Madrid, Brussels, Copenhagen" => chrono_tz::Tz::Europe__Paris, + "Perth, Western Australia" => chrono_tz::Tz::Australia__Perth, + "Prague, Central Europe" => chrono_tz::Tz::Europe__Prague, + "Qyzylorda Standard Time" => chrono_tz::Tz::Asia__Qyzylorda, + "Rangoon" => chrono_tz::Tz::Asia__Rangoon, + "Romance Standard Time" => chrono_tz::Tz::Europe__Paris, + "Romance" => chrono_tz::Tz::Europe__Paris, + "Russia Time Zone 10" => chrono_tz::Tz::Asia__Srednekolymsk, + "Russia Time Zone 11" => chrono_tz::Tz::Asia__Kamchatka, + "Russia Time Zone 3" => chrono_tz::Tz::Europe__Samara, + "Russian Standard Time" => chrono_tz::Tz::Europe__Moscow, + "Russian" => chrono_tz::Tz::Europe__Moscow, + "SA Eastern Standard Time" => chrono_tz::Tz::America__Cayenne, + "SA Eastern" => chrono_tz::Tz::America__Belem, + "SA Pacific Standard Time" => chrono_tz::Tz::America__Bogota, + "SA Pacific" => chrono_tz::Tz::America__Bogota, + "SA Western Standard Time" => chrono_tz::Tz::America__La_Paz, + "SA Western" => chrono_tz::Tz::America__La_Paz, + "SE Asia Standard Time" => chrono_tz::Tz::Asia__Bangkok, + "SE Asia" => chrono_tz::Tz::Asia__Bangkok, + "Saint Pierre Standard Time" => chrono_tz::Tz::America__Miquelon, + "Sakhalin Standard Time" => chrono_tz::Tz::Asia__Sakhalin, + "Samoa Standard Time" => chrono_tz::Tz::Pacific__Apia, + "Samoa" => chrono_tz::Tz::Pacific__Apia, + "Santiago" => chrono_tz::Tz::America__Santiago, + "Sao Tome Standard Time" => chrono_tz::Tz::Africa__Sao_Tome, + "Sarajevo, Skopje, Sofija, Vilnius, Warsaw, Zagreb" => chrono_tz::Tz::Europe__Sarajevo, + "Saratov Standard Time" => chrono_tz::Tz::Europe__Saratov, + "Saskatchewan" => chrono_tz::Tz::America__Edmonton, + "Seoul, Korea Standard time" => chrono_tz::Tz::Asia__Seoul, + "Singapore Standard Time" => chrono_tz::Tz::Asia__Singapore, + "Singapore" => chrono_tz::Tz::Asia__Singapore, + "South Africa Standard Time" => chrono_tz::Tz::Africa__Johannesburg, + "South Africa" => chrono_tz::Tz::Africa__Harare, + "Sri Jayawardenepura, Sri Lanka" => chrono_tz::Tz::Asia__Colombo, + "Sri Lanka Standard Time" => chrono_tz::Tz::Asia__Colombo, + "Sri Lanka" => chrono_tz::Tz::Asia__Colombo, + "Sudan Standard Time" => chrono_tz::Tz::Africa__Khartoum, + "Syria Standard Time" => chrono_tz::Tz::Asia__Damascus, + "Taipei Standard Time" => chrono_tz::Tz::Asia__Taipei, + "Taipei" => chrono_tz::Tz::Asia__Taipei, + "Tasmania Standard Time" => chrono_tz::Tz::Australia__Hobart, + "Tasmania" => chrono_tz::Tz::Australia__Hobart, + "Tehran" => chrono_tz::Tz::Asia__Tehran, + "Tocantins Standard Time" => chrono_tz::Tz::America__Araguaina, + "Tokyo Standard Time" => chrono_tz::Tz::Asia__Tokyo, + "Tokyo" => chrono_tz::Tz::Asia__Tokyo, + "Tomsk Standard Time" => chrono_tz::Tz::Asia__Tomsk, + "Tonga Standard Time" => chrono_tz::Tz::Pacific__Tongatapu, + "Tonga" => chrono_tz::Tz::Pacific__Tongatapu, + "Transbaikal Standard Time" => chrono_tz::Tz::Asia__Chita, + "Turkey Standard Time" => chrono_tz::Tz::Europe__Istanbul, + "Turks And Caicos Standard Time" => chrono_tz::Tz::America__Grand_Turk, + "US Eastern Standard Time" => chrono_tz::Tz::America__Indianapolis, + "US Eastern" => chrono_tz::Tz::America__Indiana__Indianapolis, + "US Mountain Standard Time" => chrono_tz::Tz::America__Phoenix, + "US Mountain" => chrono_tz::Tz::America__Phoenix, + "UTC" => chrono_tz::Tz::Etc__GMT, + "UTC+12" => chrono_tz::Tz::Etc__GMTMinus12, + "UTC+13" => chrono_tz::Tz::Etc__GMTMinus13, + "UTC-02" => chrono_tz::Tz::Etc__GMTPlus2, + "UTC-08" => chrono_tz::Tz::Etc__GMTPlus8, + "UTC-09" => chrono_tz::Tz::Etc__GMTPlus9, + "UTC-11" => chrono_tz::Tz::Etc__GMTPlus11, + "Ulaanbaatar Standard Time" => chrono_tz::Tz::Asia__Ulaanbaatar, + "Universal Coordinated Time" => chrono_tz::Tz::UTC, + "Venezuela Standard Time" => chrono_tz::Tz::America__Caracas, + "Venezuela" => chrono_tz::Tz::America__Caracas, + "Vladivostok Standard Time" => chrono_tz::Tz::Asia__Vladivostok, + "Vladivostok" => chrono_tz::Tz::Asia__Vladivostok, + "Volgograd Standard Time" => chrono_tz::Tz::Europe__Volgograd, + "W. Australia Standard Time" => chrono_tz::Tz::Australia__Perth, + "W. Australia" => chrono_tz::Tz::Australia__Perth, + "W. Central Africa Standard Time" => chrono_tz::Tz::Africa__Lagos, + "W. Central Africa" => chrono_tz::Tz::Africa__Lagos, + "W. Europe Standard Time" => chrono_tz::Tz::Europe__Berlin, + "W. Europe" => chrono_tz::Tz::Europe__Amsterdam, + "W. Mongolia Standard Time" => chrono_tz::Tz::Asia__Hovd, + "West Asia Standard Time" => chrono_tz::Tz::Asia__Tashkent, + "West Asia" => chrono_tz::Tz::Asia__Tashkent, + "West Bank Standard Time" => chrono_tz::Tz::Asia__Hebron, + "West Central Africa" => chrono_tz::Tz::Africa__Luanda, + "West Pacific Standard Time" => chrono_tz::Tz::Pacific__Port_Moresby, + "West Pacific" => chrono_tz::Tz::Pacific__Guam, + "Yakutsk Standard Time" => chrono_tz::Tz::Asia__Yakutsk, + "Yakutsk" => chrono_tz::Tz::Asia__Yakutsk, + "Yukon Standard Time" => chrono_tz::Tz::America__Whitehorse, + "Nuku'alofa, Tonga" => chrono_tz::Tz::Pacific__Tongatapu, + ); + + if let Some(tz) = result { + return Ok(Self::Tz(*tz)); + } else if let Some(zone_offset) = zone_offset { + let (zone, sign, time) = if let Some((zone, part)) = zone_offset.split_once('+') { + (zone.trim(), '-', part.trim()) + } else if let Some((zone, part)) = zone_offset.split_once('-') { + (zone.trim(), '+', part.trim()) + } else { + return Err(()); + }; + if !zone.eq_ignore_ascii_case("UTC") && !zone.eq_ignore_ascii_case("GMT") { + return Err(()); + } + let mut zone = String::with_capacity(10); + zone.push_str("Etc/GMT"); + zone.push(sign); + + for (pos, ch) in time.chars().enumerate() { + if !ch.is_ascii_digit() { + break; + } else if ch != '0' || pos > 0 { + zone.push(ch); + } + } + + if let Ok(tz) = chrono_tz::Tz::from_str(&zone) { + return Ok(Self::Tz(tz)); + } + } + Err(()) + } +} + +impl PartialEq for Tz { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Floating, Self::Floating) => true, + (Self::Tz(l0), Self::Tz(r0)) => l0 == r0, + (Self::Fixed(l0), Self::Fixed(r0)) => l0 == r0, + _ => false, + } + } +} + +impl From for Tz { + fn from(_tz: Utc) -> Self { + Self::Tz(chrono_tz::UTC) + } +} + +impl From for Tz { + fn from(tz: chrono_tz::Tz) -> Self { + Self::Tz(tz) + } +} + +impl PartialOrd for Tz { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Tz { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_id().cmp(&other.as_id()) + } +} + +impl Hash for Tz { + fn hash(&self, state: &mut H) { + self.as_id().hash(state); + } +} + +impl std::fmt::Debug for Tz { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Floating => write!(f, "Floating"), + Self::Tz(tz) => tz.fmt(f), + Self::Fixed(fixed_offset) => fixed_offset.fmt(f), + } + } +} + +impl std::fmt::Display for Tz { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Floating => write!(f, "Floating"), + Self::Tz(tz) => tz.fmt(f), + Self::Fixed(fixed_offset) => fixed_offset.fmt(f), + } + } +} + +#[derive(Clone, Copy)] +pub enum RRuleOffset { + Fixed(FixedOffset), + Tz(::Offset), + Floating, +} + +impl std::fmt::Debug for RRuleOffset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Fixed(offset) => offset.fmt(f), + Self::Tz(offset) => offset.fmt(f), + Self::Floating => write!(f, "Floating"), + } + } +} + +impl std::fmt::Display for RRuleOffset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Fixed(offset) => offset.fmt(f), + Self::Tz(offset) => offset.fmt(f), + Self::Floating => write!(f, "Floating"), + } + } +} + +impl Offset for RRuleOffset { + fn fix(&self) -> FixedOffset { + match self { + Self::Fixed(tz) => tz.fix(), + Self::Tz(tz) => tz.fix(), + Self::Floating => FixedOffset::east_opt(0).unwrap(), + } + } +} + +impl TimeZone for Tz { + type Offset = RRuleOffset; + + fn from_offset(offset: &Self::Offset) -> Self { + match offset { + RRuleOffset::Tz(offset) => Self::Tz(chrono_tz::Tz::from_offset(offset)), + RRuleOffset::Fixed(fixed_offset) => Self::Fixed(*fixed_offset), + RRuleOffset::Floating => Self::Floating, + } + } + + #[allow(deprecated)] + fn offset_from_local_date( + &self, + local: &chrono::NaiveDate, + ) -> chrono::LocalResult { + match self { + Self::Fixed(tz) => tz + .from_local_date(local) + .map(|date| RRuleOffset::Fixed(*date.offset())), + Self::Tz(tz) => tz + .from_local_date(local) + .map(|date| RRuleOffset::Tz(*date.offset())), + Self::Floating => chrono::LocalResult::Single(RRuleOffset::Floating), + } + } + + fn offset_from_local_datetime( + &self, + local: &chrono::NaiveDateTime, + ) -> chrono::LocalResult { + match self { + Self::Fixed(tz) => tz + .from_local_datetime(local) + .map(|date| RRuleOffset::Fixed(*date.offset())), + Self::Tz(tz) => tz + .from_local_datetime(local) + .map(|date| RRuleOffset::Tz(*date.offset())), + Self::Floating => chrono::LocalResult::Single(RRuleOffset::Floating), + } + } + + #[allow(deprecated)] + fn offset_from_utc_date(&self, utc: &chrono::NaiveDate) -> Self::Offset { + match self { + Self::Fixed(tz) => RRuleOffset::Fixed(*tz.from_utc_date(utc).offset()), + Self::Tz(tz) => RRuleOffset::Tz(*tz.from_utc_date(utc).offset()), + Self::Floating => RRuleOffset::Floating, + } + } + + fn offset_from_utc_datetime(&self, utc: &chrono::NaiveDateTime) -> Self::Offset { + match self { + Self::Fixed(tz) => RRuleOffset::Fixed(*tz.from_utc_datetime(utc).offset()), + Self::Tz(tz) => RRuleOffset::Tz(*tz.from_utc_datetime(utc).offset()), + Self::Floating => RRuleOffset::Floating, + } + } +} + +fn chrono_tz_to_id(tz: &chrono_tz::Tz) -> u16 { + match tz { + chrono_tz::Tz::Africa__Abidjan => 0, + chrono_tz::Tz::Africa__Accra => 1, + chrono_tz::Tz::Africa__Addis_Ababa => 2, + chrono_tz::Tz::Africa__Algiers => 3, + chrono_tz::Tz::Africa__Asmara => 4, + chrono_tz::Tz::Africa__Asmera => 5, + chrono_tz::Tz::Africa__Bamako => 6, + chrono_tz::Tz::Africa__Bangui => 7, + chrono_tz::Tz::Africa__Banjul => 8, + chrono_tz::Tz::Africa__Bissau => 9, + chrono_tz::Tz::Africa__Blantyre => 10, + chrono_tz::Tz::Africa__Brazzaville => 11, + chrono_tz::Tz::Africa__Bujumbura => 12, + chrono_tz::Tz::Africa__Cairo => 13, + chrono_tz::Tz::Africa__Casablanca => 14, + chrono_tz::Tz::Africa__Ceuta => 15, + chrono_tz::Tz::Africa__Conakry => 16, + chrono_tz::Tz::Africa__Dakar => 17, + chrono_tz::Tz::Africa__Dar_es_Salaam => 18, + chrono_tz::Tz::Africa__Djibouti => 19, + chrono_tz::Tz::Africa__Douala => 20, + chrono_tz::Tz::Africa__El_Aaiun => 21, + chrono_tz::Tz::Africa__Freetown => 22, + chrono_tz::Tz::Africa__Gaborone => 23, + chrono_tz::Tz::Africa__Harare => 24, + chrono_tz::Tz::Africa__Johannesburg => 25, + chrono_tz::Tz::Africa__Juba => 26, + chrono_tz::Tz::Africa__Kampala => 27, + chrono_tz::Tz::Africa__Khartoum => 28, + chrono_tz::Tz::Africa__Kigali => 29, + chrono_tz::Tz::Africa__Kinshasa => 30, + chrono_tz::Tz::Africa__Lagos => 31, + chrono_tz::Tz::Africa__Libreville => 32, + chrono_tz::Tz::Africa__Lome => 33, + chrono_tz::Tz::Africa__Luanda => 34, + chrono_tz::Tz::Africa__Lubumbashi => 35, + chrono_tz::Tz::Africa__Lusaka => 36, + chrono_tz::Tz::Africa__Malabo => 37, + chrono_tz::Tz::Africa__Maputo => 38, + chrono_tz::Tz::Africa__Maseru => 39, + chrono_tz::Tz::Africa__Mbabane => 40, + chrono_tz::Tz::Africa__Mogadishu => 41, + chrono_tz::Tz::Africa__Monrovia => 42, + chrono_tz::Tz::Africa__Nairobi => 43, + chrono_tz::Tz::Africa__Ndjamena => 44, + chrono_tz::Tz::Africa__Niamey => 45, + chrono_tz::Tz::Africa__Nouakchott => 46, + chrono_tz::Tz::Africa__Ouagadougou => 47, + chrono_tz::Tz::Africa__PortoNovo => 48, + chrono_tz::Tz::Africa__Sao_Tome => 49, + chrono_tz::Tz::Africa__Timbuktu => 50, + chrono_tz::Tz::Africa__Tripoli => 51, + chrono_tz::Tz::Africa__Tunis => 52, + chrono_tz::Tz::Africa__Windhoek => 53, + chrono_tz::Tz::America__Adak => 54, + chrono_tz::Tz::America__Anchorage => 55, + chrono_tz::Tz::America__Anguilla => 56, + chrono_tz::Tz::America__Antigua => 57, + chrono_tz::Tz::America__Araguaina => 58, + chrono_tz::Tz::America__Argentina__Buenos_Aires => 59, + chrono_tz::Tz::America__Argentina__Catamarca => 60, + chrono_tz::Tz::America__Argentina__ComodRivadavia => 61, + chrono_tz::Tz::America__Argentina__Cordoba => 62, + chrono_tz::Tz::America__Argentina__Jujuy => 63, + chrono_tz::Tz::America__Argentina__La_Rioja => 64, + chrono_tz::Tz::America__Argentina__Mendoza => 65, + chrono_tz::Tz::America__Argentina__Rio_Gallegos => 66, + chrono_tz::Tz::America__Argentina__Salta => 67, + chrono_tz::Tz::America__Argentina__San_Juan => 68, + chrono_tz::Tz::America__Argentina__San_Luis => 69, + chrono_tz::Tz::America__Argentina__Tucuman => 70, + chrono_tz::Tz::America__Argentina__Ushuaia => 71, + chrono_tz::Tz::America__Aruba => 72, + chrono_tz::Tz::America__Asuncion => 73, + chrono_tz::Tz::America__Atikokan => 74, + chrono_tz::Tz::America__Atka => 75, + chrono_tz::Tz::America__Bahia => 76, + chrono_tz::Tz::America__Bahia_Banderas => 77, + chrono_tz::Tz::America__Barbados => 78, + chrono_tz::Tz::America__Belem => 79, + chrono_tz::Tz::America__Belize => 80, + chrono_tz::Tz::America__BlancSablon => 81, + chrono_tz::Tz::America__Boa_Vista => 82, + chrono_tz::Tz::America__Bogota => 83, + chrono_tz::Tz::America__Boise => 84, + chrono_tz::Tz::America__Buenos_Aires => 85, + chrono_tz::Tz::America__Cambridge_Bay => 86, + chrono_tz::Tz::America__Campo_Grande => 87, + chrono_tz::Tz::America__Cancun => 88, + chrono_tz::Tz::America__Caracas => 89, + chrono_tz::Tz::America__Catamarca => 90, + chrono_tz::Tz::America__Cayenne => 91, + chrono_tz::Tz::America__Cayman => 92, + chrono_tz::Tz::America__Chicago => 93, + chrono_tz::Tz::America__Chihuahua => 94, + chrono_tz::Tz::America__Ciudad_Juarez => 95, + chrono_tz::Tz::America__Coral_Harbour => 96, + chrono_tz::Tz::America__Cordoba => 97, + chrono_tz::Tz::America__Costa_Rica => 98, + chrono_tz::Tz::America__Coyhaique => 99, + chrono_tz::Tz::America__Creston => 100, + chrono_tz::Tz::America__Cuiaba => 101, + chrono_tz::Tz::America__Curacao => 102, + chrono_tz::Tz::America__Danmarkshavn => 103, + chrono_tz::Tz::America__Dawson => 104, + chrono_tz::Tz::America__Dawson_Creek => 105, + chrono_tz::Tz::America__Denver => 106, + chrono_tz::Tz::America__Detroit => 107, + chrono_tz::Tz::America__Dominica => 108, + chrono_tz::Tz::America__Edmonton => 109, + chrono_tz::Tz::America__Eirunepe => 110, + chrono_tz::Tz::America__El_Salvador => 111, + chrono_tz::Tz::America__Ensenada => 112, + chrono_tz::Tz::America__Fort_Nelson => 113, + chrono_tz::Tz::America__Fort_Wayne => 114, + chrono_tz::Tz::America__Fortaleza => 115, + chrono_tz::Tz::America__Glace_Bay => 116, + chrono_tz::Tz::America__Godthab => 117, + chrono_tz::Tz::America__Goose_Bay => 118, + chrono_tz::Tz::America__Grand_Turk => 119, + chrono_tz::Tz::America__Grenada => 120, + chrono_tz::Tz::America__Guadeloupe => 121, + chrono_tz::Tz::America__Guatemala => 122, + chrono_tz::Tz::America__Guayaquil => 123, + chrono_tz::Tz::America__Guyana => 124, + chrono_tz::Tz::America__Halifax => 125, + chrono_tz::Tz::America__Havana => 126, + chrono_tz::Tz::America__Hermosillo => 127, + chrono_tz::Tz::America__Indiana__Indianapolis => 128, + chrono_tz::Tz::America__Indiana__Knox => 129, + chrono_tz::Tz::America__Indiana__Marengo => 130, + chrono_tz::Tz::America__Indiana__Petersburg => 131, + chrono_tz::Tz::America__Indiana__Tell_City => 132, + chrono_tz::Tz::America__Indiana__Vevay => 133, + chrono_tz::Tz::America__Indiana__Vincennes => 134, + chrono_tz::Tz::America__Indiana__Winamac => 135, + chrono_tz::Tz::America__Indianapolis => 136, + chrono_tz::Tz::America__Inuvik => 137, + chrono_tz::Tz::America__Iqaluit => 138, + chrono_tz::Tz::America__Jamaica => 139, + chrono_tz::Tz::America__Jujuy => 140, + chrono_tz::Tz::America__Juneau => 141, + chrono_tz::Tz::America__Kentucky__Louisville => 142, + chrono_tz::Tz::America__Kentucky__Monticello => 143, + chrono_tz::Tz::America__Knox_IN => 144, + chrono_tz::Tz::America__Kralendijk => 145, + chrono_tz::Tz::America__La_Paz => 146, + chrono_tz::Tz::America__Lima => 147, + chrono_tz::Tz::America__Los_Angeles => 148, + chrono_tz::Tz::America__Louisville => 149, + chrono_tz::Tz::America__Lower_Princes => 150, + chrono_tz::Tz::America__Maceio => 151, + chrono_tz::Tz::America__Managua => 152, + chrono_tz::Tz::America__Manaus => 153, + chrono_tz::Tz::America__Marigot => 154, + chrono_tz::Tz::America__Martinique => 155, + chrono_tz::Tz::America__Matamoros => 156, + chrono_tz::Tz::America__Mazatlan => 157, + chrono_tz::Tz::America__Mendoza => 158, + chrono_tz::Tz::America__Menominee => 159, + chrono_tz::Tz::America__Merida => 160, + chrono_tz::Tz::America__Metlakatla => 161, + chrono_tz::Tz::America__Mexico_City => 162, + chrono_tz::Tz::America__Miquelon => 163, + chrono_tz::Tz::America__Moncton => 164, + chrono_tz::Tz::America__Monterrey => 165, + chrono_tz::Tz::America__Montevideo => 166, + chrono_tz::Tz::America__Montreal => 167, + chrono_tz::Tz::America__Montserrat => 168, + chrono_tz::Tz::America__Nassau => 169, + chrono_tz::Tz::America__New_York => 170, + chrono_tz::Tz::America__Nipigon => 171, + chrono_tz::Tz::America__Nome => 172, + chrono_tz::Tz::America__Noronha => 173, + chrono_tz::Tz::America__North_Dakota__Beulah => 174, + chrono_tz::Tz::America__North_Dakota__Center => 175, + chrono_tz::Tz::America__North_Dakota__New_Salem => 176, + chrono_tz::Tz::America__Nuuk => 177, + chrono_tz::Tz::America__Ojinaga => 178, + chrono_tz::Tz::America__Panama => 179, + chrono_tz::Tz::America__Pangnirtung => 180, + chrono_tz::Tz::America__Paramaribo => 181, + chrono_tz::Tz::America__Phoenix => 182, + chrono_tz::Tz::America__PortauPrince => 183, + chrono_tz::Tz::America__Port_of_Spain => 184, + chrono_tz::Tz::America__Porto_Acre => 185, + chrono_tz::Tz::America__Porto_Velho => 186, + chrono_tz::Tz::America__Puerto_Rico => 187, + chrono_tz::Tz::America__Punta_Arenas => 188, + chrono_tz::Tz::America__Rainy_River => 189, + chrono_tz::Tz::America__Rankin_Inlet => 190, + chrono_tz::Tz::America__Recife => 191, + chrono_tz::Tz::America__Regina => 192, + chrono_tz::Tz::America__Resolute => 193, + chrono_tz::Tz::America__Rio_Branco => 194, + chrono_tz::Tz::America__Rosario => 195, + chrono_tz::Tz::America__Santa_Isabel => 196, + chrono_tz::Tz::America__Santarem => 197, + chrono_tz::Tz::America__Santiago => 198, + chrono_tz::Tz::America__Santo_Domingo => 199, + chrono_tz::Tz::America__Sao_Paulo => 200, + chrono_tz::Tz::America__Scoresbysund => 201, + chrono_tz::Tz::America__Shiprock => 202, + chrono_tz::Tz::America__Sitka => 203, + chrono_tz::Tz::America__St_Barthelemy => 204, + chrono_tz::Tz::America__St_Johns => 205, + chrono_tz::Tz::America__St_Kitts => 206, + chrono_tz::Tz::America__St_Lucia => 207, + chrono_tz::Tz::America__St_Thomas => 208, + chrono_tz::Tz::America__St_Vincent => 209, + chrono_tz::Tz::America__Swift_Current => 210, + chrono_tz::Tz::America__Tegucigalpa => 211, + chrono_tz::Tz::America__Thule => 212, + chrono_tz::Tz::America__Thunder_Bay => 213, + chrono_tz::Tz::America__Tijuana => 214, + chrono_tz::Tz::America__Toronto => 215, + chrono_tz::Tz::America__Tortola => 216, + chrono_tz::Tz::America__Vancouver => 217, + chrono_tz::Tz::America__Virgin => 218, + chrono_tz::Tz::America__Whitehorse => 219, + chrono_tz::Tz::America__Winnipeg => 220, + chrono_tz::Tz::America__Yakutat => 221, + chrono_tz::Tz::America__Yellowknife => 222, + chrono_tz::Tz::Antarctica__Casey => 223, + chrono_tz::Tz::Antarctica__Davis => 224, + chrono_tz::Tz::Antarctica__DumontDUrville => 225, + chrono_tz::Tz::Antarctica__Macquarie => 226, + chrono_tz::Tz::Antarctica__Mawson => 227, + chrono_tz::Tz::Antarctica__McMurdo => 228, + chrono_tz::Tz::Antarctica__Palmer => 229, + chrono_tz::Tz::Antarctica__Rothera => 230, + chrono_tz::Tz::Antarctica__South_Pole => 231, + chrono_tz::Tz::Antarctica__Syowa => 232, + chrono_tz::Tz::Antarctica__Troll => 233, + chrono_tz::Tz::Antarctica__Vostok => 234, + chrono_tz::Tz::Arctic__Longyearbyen => 235, + chrono_tz::Tz::Asia__Aden => 236, + chrono_tz::Tz::Asia__Almaty => 237, + chrono_tz::Tz::Asia__Amman => 238, + chrono_tz::Tz::Asia__Anadyr => 239, + chrono_tz::Tz::Asia__Aqtau => 240, + chrono_tz::Tz::Asia__Aqtobe => 241, + chrono_tz::Tz::Asia__Ashgabat => 242, + chrono_tz::Tz::Asia__Ashkhabad => 243, + chrono_tz::Tz::Asia__Atyrau => 244, + chrono_tz::Tz::Asia__Baghdad => 245, + chrono_tz::Tz::Asia__Bahrain => 246, + chrono_tz::Tz::Asia__Baku => 247, + chrono_tz::Tz::Asia__Bangkok => 248, + chrono_tz::Tz::Asia__Barnaul => 249, + chrono_tz::Tz::Asia__Beirut => 250, + chrono_tz::Tz::Asia__Bishkek => 251, + chrono_tz::Tz::Asia__Brunei => 252, + chrono_tz::Tz::Asia__Calcutta => 253, + chrono_tz::Tz::Asia__Chita => 254, + chrono_tz::Tz::Asia__Choibalsan => 255, + chrono_tz::Tz::Asia__Chongqing => 256, + chrono_tz::Tz::Asia__Chungking => 257, + chrono_tz::Tz::Asia__Colombo => 258, + chrono_tz::Tz::Asia__Dacca => 259, + chrono_tz::Tz::Asia__Damascus => 260, + chrono_tz::Tz::Asia__Dhaka => 261, + chrono_tz::Tz::Asia__Dili => 262, + chrono_tz::Tz::Asia__Dubai => 263, + chrono_tz::Tz::Asia__Dushanbe => 264, + chrono_tz::Tz::Asia__Famagusta => 265, + chrono_tz::Tz::Asia__Gaza => 266, + chrono_tz::Tz::Asia__Harbin => 267, + chrono_tz::Tz::Asia__Hebron => 268, + chrono_tz::Tz::Asia__Ho_Chi_Minh => 269, + chrono_tz::Tz::Asia__Hong_Kong => 270, + chrono_tz::Tz::Asia__Hovd => 271, + chrono_tz::Tz::Asia__Irkutsk => 272, + chrono_tz::Tz::Asia__Istanbul => 273, + chrono_tz::Tz::Asia__Jakarta => 274, + chrono_tz::Tz::Asia__Jayapura => 275, + chrono_tz::Tz::Asia__Jerusalem => 276, + chrono_tz::Tz::Asia__Kabul => 277, + chrono_tz::Tz::Asia__Kamchatka => 278, + chrono_tz::Tz::Asia__Karachi => 279, + chrono_tz::Tz::Asia__Kashgar => 280, + chrono_tz::Tz::Asia__Kathmandu => 281, + chrono_tz::Tz::Asia__Katmandu => 282, + chrono_tz::Tz::Asia__Khandyga => 283, + chrono_tz::Tz::Asia__Kolkata => 284, + chrono_tz::Tz::Asia__Krasnoyarsk => 285, + chrono_tz::Tz::Asia__Kuala_Lumpur => 286, + chrono_tz::Tz::Asia__Kuching => 287, + chrono_tz::Tz::Asia__Kuwait => 288, + chrono_tz::Tz::Asia__Macao => 289, + chrono_tz::Tz::Asia__Macau => 290, + chrono_tz::Tz::Asia__Magadan => 291, + chrono_tz::Tz::Asia__Makassar => 292, + chrono_tz::Tz::Asia__Manila => 293, + chrono_tz::Tz::Asia__Muscat => 294, + chrono_tz::Tz::Asia__Nicosia => 295, + chrono_tz::Tz::Asia__Novokuznetsk => 296, + chrono_tz::Tz::Asia__Novosibirsk => 297, + chrono_tz::Tz::Asia__Omsk => 298, + chrono_tz::Tz::Asia__Oral => 299, + chrono_tz::Tz::Asia__Phnom_Penh => 300, + chrono_tz::Tz::Asia__Pontianak => 301, + chrono_tz::Tz::Asia__Pyongyang => 302, + chrono_tz::Tz::Asia__Qatar => 303, + chrono_tz::Tz::Asia__Qostanay => 304, + chrono_tz::Tz::Asia__Qyzylorda => 305, + chrono_tz::Tz::Asia__Rangoon => 306, + chrono_tz::Tz::Asia__Riyadh => 307, + chrono_tz::Tz::Asia__Saigon => 308, + chrono_tz::Tz::Asia__Sakhalin => 309, + chrono_tz::Tz::Asia__Samarkand => 310, + chrono_tz::Tz::Asia__Seoul => 311, + chrono_tz::Tz::Asia__Shanghai => 312, + chrono_tz::Tz::Asia__Singapore => 313, + chrono_tz::Tz::Asia__Srednekolymsk => 314, + chrono_tz::Tz::Asia__Taipei => 315, + chrono_tz::Tz::Asia__Tashkent => 316, + chrono_tz::Tz::Asia__Tbilisi => 317, + chrono_tz::Tz::Asia__Tehran => 318, + chrono_tz::Tz::Asia__Tel_Aviv => 319, + chrono_tz::Tz::Asia__Thimbu => 320, + chrono_tz::Tz::Asia__Thimphu => 321, + chrono_tz::Tz::Asia__Tokyo => 322, + chrono_tz::Tz::Asia__Tomsk => 323, + chrono_tz::Tz::Asia__Ujung_Pandang => 324, + chrono_tz::Tz::Asia__Ulaanbaatar => 325, + chrono_tz::Tz::Asia__Ulan_Bator => 326, + chrono_tz::Tz::Asia__Urumqi => 327, + chrono_tz::Tz::Asia__UstNera => 328, + chrono_tz::Tz::Asia__Vientiane => 329, + chrono_tz::Tz::Asia__Vladivostok => 330, + chrono_tz::Tz::Asia__Yakutsk => 331, + chrono_tz::Tz::Asia__Yangon => 332, + chrono_tz::Tz::Asia__Yekaterinburg => 333, + chrono_tz::Tz::Asia__Yerevan => 334, + chrono_tz::Tz::Atlantic__Azores => 335, + chrono_tz::Tz::Atlantic__Bermuda => 336, + chrono_tz::Tz::Atlantic__Canary => 337, + chrono_tz::Tz::Atlantic__Cape_Verde => 338, + chrono_tz::Tz::Atlantic__Faeroe => 339, + chrono_tz::Tz::Atlantic__Faroe => 340, + chrono_tz::Tz::Atlantic__Jan_Mayen => 341, + chrono_tz::Tz::Atlantic__Madeira => 342, + chrono_tz::Tz::Atlantic__Reykjavik => 343, + chrono_tz::Tz::Atlantic__South_Georgia => 344, + chrono_tz::Tz::Atlantic__St_Helena => 345, + chrono_tz::Tz::Atlantic__Stanley => 346, + chrono_tz::Tz::Australia__ACT => 347, + chrono_tz::Tz::Australia__Adelaide => 348, + chrono_tz::Tz::Australia__Brisbane => 349, + chrono_tz::Tz::Australia__Broken_Hill => 350, + chrono_tz::Tz::Australia__Canberra => 351, + chrono_tz::Tz::Australia__Currie => 352, + chrono_tz::Tz::Australia__Darwin => 353, + chrono_tz::Tz::Australia__Eucla => 354, + chrono_tz::Tz::Australia__Hobart => 355, + chrono_tz::Tz::Australia__LHI => 356, + chrono_tz::Tz::Australia__Lindeman => 357, + chrono_tz::Tz::Australia__Lord_Howe => 358, + chrono_tz::Tz::Australia__Melbourne => 359, + chrono_tz::Tz::Australia__NSW => 360, + chrono_tz::Tz::Australia__North => 361, + chrono_tz::Tz::Australia__Perth => 362, + chrono_tz::Tz::Australia__Queensland => 363, + chrono_tz::Tz::Australia__South => 364, + chrono_tz::Tz::Australia__Sydney => 365, + chrono_tz::Tz::Australia__Tasmania => 366, + chrono_tz::Tz::Australia__Victoria => 367, + chrono_tz::Tz::Australia__West => 368, + chrono_tz::Tz::Australia__Yancowinna => 369, + chrono_tz::Tz::Brazil__Acre => 370, + chrono_tz::Tz::Brazil__DeNoronha => 371, + chrono_tz::Tz::Brazil__East => 372, + chrono_tz::Tz::Brazil__West => 373, + chrono_tz::Tz::CET => 374, + chrono_tz::Tz::CST6CDT => 375, + chrono_tz::Tz::Canada__Atlantic => 376, + chrono_tz::Tz::Canada__Central => 377, + chrono_tz::Tz::Canada__Eastern => 378, + chrono_tz::Tz::Canada__Mountain => 379, + chrono_tz::Tz::Canada__Newfoundland => 380, + chrono_tz::Tz::Canada__Pacific => 381, + chrono_tz::Tz::Canada__Saskatchewan => 382, + chrono_tz::Tz::Canada__Yukon => 383, + chrono_tz::Tz::Chile__Continental => 384, + chrono_tz::Tz::Chile__EasterIsland => 385, + chrono_tz::Tz::Cuba => 386, + chrono_tz::Tz::EET => 387, + chrono_tz::Tz::EST => 388, + chrono_tz::Tz::EST5EDT => 389, + chrono_tz::Tz::Egypt => 390, + chrono_tz::Tz::Eire => 391, + chrono_tz::Tz::Etc__GMT => 392, + chrono_tz::Tz::Etc__GMTPlus0 => 393, + chrono_tz::Tz::Etc__GMTPlus1 => 394, + chrono_tz::Tz::Etc__GMTPlus10 => 395, + chrono_tz::Tz::Etc__GMTPlus11 => 396, + chrono_tz::Tz::Etc__GMTPlus12 => 397, + chrono_tz::Tz::Etc__GMTPlus2 => 398, + chrono_tz::Tz::Etc__GMTPlus3 => 399, + chrono_tz::Tz::Etc__GMTPlus4 => 400, + chrono_tz::Tz::Etc__GMTPlus5 => 401, + chrono_tz::Tz::Etc__GMTPlus6 => 402, + chrono_tz::Tz::Etc__GMTPlus7 => 403, + chrono_tz::Tz::Etc__GMTPlus8 => 404, + chrono_tz::Tz::Etc__GMTPlus9 => 405, + chrono_tz::Tz::Etc__GMTMinus0 => 406, + chrono_tz::Tz::Etc__GMTMinus1 => 407, + chrono_tz::Tz::Etc__GMTMinus10 => 408, + chrono_tz::Tz::Etc__GMTMinus11 => 409, + chrono_tz::Tz::Etc__GMTMinus12 => 410, + chrono_tz::Tz::Etc__GMTMinus13 => 411, + chrono_tz::Tz::Etc__GMTMinus14 => 412, + chrono_tz::Tz::Etc__GMTMinus2 => 413, + chrono_tz::Tz::Etc__GMTMinus3 => 414, + chrono_tz::Tz::Etc__GMTMinus4 => 415, + chrono_tz::Tz::Etc__GMTMinus5 => 416, + chrono_tz::Tz::Etc__GMTMinus6 => 417, + chrono_tz::Tz::Etc__GMTMinus7 => 418, + chrono_tz::Tz::Etc__GMTMinus8 => 419, + chrono_tz::Tz::Etc__GMTMinus9 => 420, + chrono_tz::Tz::Etc__GMT0 => 421, + chrono_tz::Tz::Etc__Greenwich => 422, + chrono_tz::Tz::Etc__UCT => 423, + chrono_tz::Tz::Etc__UTC => 424, + chrono_tz::Tz::Etc__Universal => 425, + chrono_tz::Tz::Etc__Zulu => 426, + chrono_tz::Tz::Europe__Amsterdam => 427, + chrono_tz::Tz::Europe__Andorra => 428, + chrono_tz::Tz::Europe__Astrakhan => 429, + chrono_tz::Tz::Europe__Athens => 430, + chrono_tz::Tz::Europe__Belfast => 431, + chrono_tz::Tz::Europe__Belgrade => 432, + chrono_tz::Tz::Europe__Berlin => 433, + chrono_tz::Tz::Europe__Bratislava => 434, + chrono_tz::Tz::Europe__Brussels => 435, + chrono_tz::Tz::Europe__Bucharest => 436, + chrono_tz::Tz::Europe__Budapest => 437, + chrono_tz::Tz::Europe__Busingen => 438, + chrono_tz::Tz::Europe__Chisinau => 439, + chrono_tz::Tz::Europe__Copenhagen => 440, + chrono_tz::Tz::Europe__Dublin => 441, + chrono_tz::Tz::Europe__Gibraltar => 442, + chrono_tz::Tz::Europe__Guernsey => 443, + chrono_tz::Tz::Europe__Helsinki => 444, + chrono_tz::Tz::Europe__Isle_of_Man => 445, + chrono_tz::Tz::Europe__Istanbul => 446, + chrono_tz::Tz::Europe__Jersey => 447, + chrono_tz::Tz::Europe__Kaliningrad => 448, + chrono_tz::Tz::Europe__Kiev => 449, + chrono_tz::Tz::Europe__Kirov => 450, + chrono_tz::Tz::Europe__Kyiv => 451, + chrono_tz::Tz::Europe__Lisbon => 452, + chrono_tz::Tz::Europe__Ljubljana => 453, + chrono_tz::Tz::Europe__London => 454, + chrono_tz::Tz::Europe__Luxembourg => 455, + chrono_tz::Tz::Europe__Madrid => 456, + chrono_tz::Tz::Europe__Malta => 457, + chrono_tz::Tz::Europe__Mariehamn => 458, + chrono_tz::Tz::Europe__Minsk => 459, + chrono_tz::Tz::Europe__Monaco => 460, + chrono_tz::Tz::Europe__Moscow => 461, + chrono_tz::Tz::Europe__Nicosia => 462, + chrono_tz::Tz::Europe__Oslo => 463, + chrono_tz::Tz::Europe__Paris => 464, + chrono_tz::Tz::Europe__Podgorica => 465, + chrono_tz::Tz::Europe__Prague => 466, + chrono_tz::Tz::Europe__Riga => 467, + chrono_tz::Tz::Europe__Rome => 468, + chrono_tz::Tz::Europe__Samara => 469, + chrono_tz::Tz::Europe__San_Marino => 470, + chrono_tz::Tz::Europe__Sarajevo => 471, + chrono_tz::Tz::Europe__Saratov => 472, + chrono_tz::Tz::Europe__Simferopol => 473, + chrono_tz::Tz::Europe__Skopje => 474, + chrono_tz::Tz::Europe__Sofia => 475, + chrono_tz::Tz::Europe__Stockholm => 476, + chrono_tz::Tz::Europe__Tallinn => 477, + chrono_tz::Tz::Europe__Tirane => 478, + chrono_tz::Tz::Europe__Tiraspol => 479, + chrono_tz::Tz::Europe__Ulyanovsk => 480, + chrono_tz::Tz::Europe__Uzhgorod => 481, + chrono_tz::Tz::Europe__Vaduz => 482, + chrono_tz::Tz::Europe__Vatican => 483, + chrono_tz::Tz::Europe__Vienna => 484, + chrono_tz::Tz::Europe__Vilnius => 485, + chrono_tz::Tz::Europe__Volgograd => 486, + chrono_tz::Tz::Europe__Warsaw => 487, + chrono_tz::Tz::Europe__Zagreb => 488, + chrono_tz::Tz::Europe__Zaporozhye => 489, + chrono_tz::Tz::Europe__Zurich => 490, + chrono_tz::Tz::GB => 491, + chrono_tz::Tz::GBEire => 492, + chrono_tz::Tz::GMT => 493, + chrono_tz::Tz::GMTPlus0 => 494, + chrono_tz::Tz::GMTMinus0 => 495, + chrono_tz::Tz::GMT0 => 496, + chrono_tz::Tz::Greenwich => 497, + chrono_tz::Tz::HST => 498, + chrono_tz::Tz::Hongkong => 499, + chrono_tz::Tz::Iceland => 500, + chrono_tz::Tz::Indian__Antananarivo => 501, + chrono_tz::Tz::Indian__Chagos => 502, + chrono_tz::Tz::Indian__Christmas => 503, + chrono_tz::Tz::Indian__Cocos => 504, + chrono_tz::Tz::Indian__Comoro => 505, + chrono_tz::Tz::Indian__Kerguelen => 506, + chrono_tz::Tz::Indian__Mahe => 507, + chrono_tz::Tz::Indian__Maldives => 508, + chrono_tz::Tz::Indian__Mauritius => 509, + chrono_tz::Tz::Indian__Mayotte => 510, + chrono_tz::Tz::Indian__Reunion => 511, + chrono_tz::Tz::Iran => 512, + chrono_tz::Tz::Israel => 513, + chrono_tz::Tz::Jamaica => 514, + chrono_tz::Tz::Japan => 515, + chrono_tz::Tz::Kwajalein => 516, + chrono_tz::Tz::Libya => 517, + chrono_tz::Tz::MET => 518, + chrono_tz::Tz::MST => 519, + chrono_tz::Tz::MST7MDT => 520, + chrono_tz::Tz::Mexico__BajaNorte => 521, + chrono_tz::Tz::Mexico__BajaSur => 522, + chrono_tz::Tz::Mexico__General => 523, + chrono_tz::Tz::NZ => 524, + chrono_tz::Tz::NZCHAT => 525, + chrono_tz::Tz::Navajo => 526, + chrono_tz::Tz::PRC => 527, + chrono_tz::Tz::PST8PDT => 528, + chrono_tz::Tz::Pacific__Apia => 529, + chrono_tz::Tz::Pacific__Auckland => 530, + chrono_tz::Tz::Pacific__Bougainville => 531, + chrono_tz::Tz::Pacific__Chatham => 532, + chrono_tz::Tz::Pacific__Chuuk => 533, + chrono_tz::Tz::Pacific__Easter => 534, + chrono_tz::Tz::Pacific__Efate => 535, + chrono_tz::Tz::Pacific__Enderbury => 536, + chrono_tz::Tz::Pacific__Fakaofo => 537, + chrono_tz::Tz::Pacific__Fiji => 538, + chrono_tz::Tz::Pacific__Funafuti => 539, + chrono_tz::Tz::Pacific__Galapagos => 540, + chrono_tz::Tz::Pacific__Gambier => 541, + chrono_tz::Tz::Pacific__Guadalcanal => 542, + chrono_tz::Tz::Pacific__Guam => 543, + chrono_tz::Tz::Pacific__Honolulu => 544, + chrono_tz::Tz::Pacific__Johnston => 545, + chrono_tz::Tz::Pacific__Kanton => 546, + chrono_tz::Tz::Pacific__Kiritimati => 547, + chrono_tz::Tz::Pacific__Kosrae => 548, + chrono_tz::Tz::Pacific__Kwajalein => 549, + chrono_tz::Tz::Pacific__Majuro => 550, + chrono_tz::Tz::Pacific__Marquesas => 551, + chrono_tz::Tz::Pacific__Midway => 552, + chrono_tz::Tz::Pacific__Nauru => 553, + chrono_tz::Tz::Pacific__Niue => 554, + chrono_tz::Tz::Pacific__Norfolk => 555, + chrono_tz::Tz::Pacific__Noumea => 556, + chrono_tz::Tz::Pacific__Pago_Pago => 557, + chrono_tz::Tz::Pacific__Palau => 558, + chrono_tz::Tz::Pacific__Pitcairn => 559, + chrono_tz::Tz::Pacific__Pohnpei => 560, + chrono_tz::Tz::Pacific__Ponape => 561, + chrono_tz::Tz::Pacific__Port_Moresby => 562, + chrono_tz::Tz::Pacific__Rarotonga => 563, + chrono_tz::Tz::Pacific__Saipan => 564, + chrono_tz::Tz::Pacific__Samoa => 565, + chrono_tz::Tz::Pacific__Tahiti => 566, + chrono_tz::Tz::Pacific__Tarawa => 567, + chrono_tz::Tz::Pacific__Tongatapu => 568, + chrono_tz::Tz::Pacific__Truk => 569, + chrono_tz::Tz::Pacific__Wake => 570, + chrono_tz::Tz::Pacific__Wallis => 571, + chrono_tz::Tz::Pacific__Yap => 572, + chrono_tz::Tz::Poland => 573, + chrono_tz::Tz::Portugal => 574, + chrono_tz::Tz::ROC => 575, + chrono_tz::Tz::ROK => 576, + chrono_tz::Tz::Singapore => 577, + chrono_tz::Tz::Turkey => 578, + chrono_tz::Tz::UCT => 579, + chrono_tz::Tz::US__Alaska => 580, + chrono_tz::Tz::US__Aleutian => 581, + chrono_tz::Tz::US__Arizona => 582, + chrono_tz::Tz::US__Central => 583, + chrono_tz::Tz::US__EastIndiana => 584, + chrono_tz::Tz::US__Eastern => 585, + chrono_tz::Tz::US__Hawaii => 586, + chrono_tz::Tz::US__IndianaStarke => 587, + chrono_tz::Tz::US__Michigan => 588, + chrono_tz::Tz::US__Mountain => 589, + chrono_tz::Tz::US__Pacific => 590, + chrono_tz::Tz::US__Samoa => 591, + chrono_tz::Tz::UTC => 592, + chrono_tz::Tz::Universal => 593, + chrono_tz::Tz::WSU => 594, + chrono_tz::Tz::WET => 595, + chrono_tz::Tz::Zulu => 596, + } +} + +fn id_to_chrono_tz(id: u16) -> Option { + match id { + 0 => Some(chrono_tz::Tz::Africa__Abidjan), + 1 => Some(chrono_tz::Tz::Africa__Accra), + 2 => Some(chrono_tz::Tz::Africa__Addis_Ababa), + 3 => Some(chrono_tz::Tz::Africa__Algiers), + 4 => Some(chrono_tz::Tz::Africa__Asmara), + 5 => Some(chrono_tz::Tz::Africa__Asmera), + 6 => Some(chrono_tz::Tz::Africa__Bamako), + 7 => Some(chrono_tz::Tz::Africa__Bangui), + 8 => Some(chrono_tz::Tz::Africa__Banjul), + 9 => Some(chrono_tz::Tz::Africa__Bissau), + 10 => Some(chrono_tz::Tz::Africa__Blantyre), + 11 => Some(chrono_tz::Tz::Africa__Brazzaville), + 12 => Some(chrono_tz::Tz::Africa__Bujumbura), + 13 => Some(chrono_tz::Tz::Africa__Cairo), + 14 => Some(chrono_tz::Tz::Africa__Casablanca), + 15 => Some(chrono_tz::Tz::Africa__Ceuta), + 16 => Some(chrono_tz::Tz::Africa__Conakry), + 17 => Some(chrono_tz::Tz::Africa__Dakar), + 18 => Some(chrono_tz::Tz::Africa__Dar_es_Salaam), + 19 => Some(chrono_tz::Tz::Africa__Djibouti), + 20 => Some(chrono_tz::Tz::Africa__Douala), + 21 => Some(chrono_tz::Tz::Africa__El_Aaiun), + 22 => Some(chrono_tz::Tz::Africa__Freetown), + 23 => Some(chrono_tz::Tz::Africa__Gaborone), + 24 => Some(chrono_tz::Tz::Africa__Harare), + 25 => Some(chrono_tz::Tz::Africa__Johannesburg), + 26 => Some(chrono_tz::Tz::Africa__Juba), + 27 => Some(chrono_tz::Tz::Africa__Kampala), + 28 => Some(chrono_tz::Tz::Africa__Khartoum), + 29 => Some(chrono_tz::Tz::Africa__Kigali), + 30 => Some(chrono_tz::Tz::Africa__Kinshasa), + 31 => Some(chrono_tz::Tz::Africa__Lagos), + 32 => Some(chrono_tz::Tz::Africa__Libreville), + 33 => Some(chrono_tz::Tz::Africa__Lome), + 34 => Some(chrono_tz::Tz::Africa__Luanda), + 35 => Some(chrono_tz::Tz::Africa__Lubumbashi), + 36 => Some(chrono_tz::Tz::Africa__Lusaka), + 37 => Some(chrono_tz::Tz::Africa__Malabo), + 38 => Some(chrono_tz::Tz::Africa__Maputo), + 39 => Some(chrono_tz::Tz::Africa__Maseru), + 40 => Some(chrono_tz::Tz::Africa__Mbabane), + 41 => Some(chrono_tz::Tz::Africa__Mogadishu), + 42 => Some(chrono_tz::Tz::Africa__Monrovia), + 43 => Some(chrono_tz::Tz::Africa__Nairobi), + 44 => Some(chrono_tz::Tz::Africa__Ndjamena), + 45 => Some(chrono_tz::Tz::Africa__Niamey), + 46 => Some(chrono_tz::Tz::Africa__Nouakchott), + 47 => Some(chrono_tz::Tz::Africa__Ouagadougou), + 48 => Some(chrono_tz::Tz::Africa__PortoNovo), + 49 => Some(chrono_tz::Tz::Africa__Sao_Tome), + 50 => Some(chrono_tz::Tz::Africa__Timbuktu), + 51 => Some(chrono_tz::Tz::Africa__Tripoli), + 52 => Some(chrono_tz::Tz::Africa__Tunis), + 53 => Some(chrono_tz::Tz::Africa__Windhoek), + 54 => Some(chrono_tz::Tz::America__Adak), + 55 => Some(chrono_tz::Tz::America__Anchorage), + 56 => Some(chrono_tz::Tz::America__Anguilla), + 57 => Some(chrono_tz::Tz::America__Antigua), + 58 => Some(chrono_tz::Tz::America__Araguaina), + 59 => Some(chrono_tz::Tz::America__Argentina__Buenos_Aires), + 60 => Some(chrono_tz::Tz::America__Argentina__Catamarca), + 61 => Some(chrono_tz::Tz::America__Argentina__ComodRivadavia), + 62 => Some(chrono_tz::Tz::America__Argentina__Cordoba), + 63 => Some(chrono_tz::Tz::America__Argentina__Jujuy), + 64 => Some(chrono_tz::Tz::America__Argentina__La_Rioja), + 65 => Some(chrono_tz::Tz::America__Argentina__Mendoza), + 66 => Some(chrono_tz::Tz::America__Argentina__Rio_Gallegos), + 67 => Some(chrono_tz::Tz::America__Argentina__Salta), + 68 => Some(chrono_tz::Tz::America__Argentina__San_Juan), + 69 => Some(chrono_tz::Tz::America__Argentina__San_Luis), + 70 => Some(chrono_tz::Tz::America__Argentina__Tucuman), + 71 => Some(chrono_tz::Tz::America__Argentina__Ushuaia), + 72 => Some(chrono_tz::Tz::America__Aruba), + 73 => Some(chrono_tz::Tz::America__Asuncion), + 74 => Some(chrono_tz::Tz::America__Atikokan), + 75 => Some(chrono_tz::Tz::America__Atka), + 76 => Some(chrono_tz::Tz::America__Bahia), + 77 => Some(chrono_tz::Tz::America__Bahia_Banderas), + 78 => Some(chrono_tz::Tz::America__Barbados), + 79 => Some(chrono_tz::Tz::America__Belem), + 80 => Some(chrono_tz::Tz::America__Belize), + 81 => Some(chrono_tz::Tz::America__BlancSablon), + 82 => Some(chrono_tz::Tz::America__Boa_Vista), + 83 => Some(chrono_tz::Tz::America__Bogota), + 84 => Some(chrono_tz::Tz::America__Boise), + 85 => Some(chrono_tz::Tz::America__Buenos_Aires), + 86 => Some(chrono_tz::Tz::America__Cambridge_Bay), + 87 => Some(chrono_tz::Tz::America__Campo_Grande), + 88 => Some(chrono_tz::Tz::America__Cancun), + 89 => Some(chrono_tz::Tz::America__Caracas), + 90 => Some(chrono_tz::Tz::America__Catamarca), + 91 => Some(chrono_tz::Tz::America__Cayenne), + 92 => Some(chrono_tz::Tz::America__Cayman), + 93 => Some(chrono_tz::Tz::America__Chicago), + 94 => Some(chrono_tz::Tz::America__Chihuahua), + 95 => Some(chrono_tz::Tz::America__Ciudad_Juarez), + 96 => Some(chrono_tz::Tz::America__Coral_Harbour), + 97 => Some(chrono_tz::Tz::America__Cordoba), + 98 => Some(chrono_tz::Tz::America__Costa_Rica), + 99 => Some(chrono_tz::Tz::America__Coyhaique), + 100 => Some(chrono_tz::Tz::America__Creston), + 101 => Some(chrono_tz::Tz::America__Cuiaba), + 102 => Some(chrono_tz::Tz::America__Curacao), + 103 => Some(chrono_tz::Tz::America__Danmarkshavn), + 104 => Some(chrono_tz::Tz::America__Dawson), + 105 => Some(chrono_tz::Tz::America__Dawson_Creek), + 106 => Some(chrono_tz::Tz::America__Denver), + 107 => Some(chrono_tz::Tz::America__Detroit), + 108 => Some(chrono_tz::Tz::America__Dominica), + 109 => Some(chrono_tz::Tz::America__Edmonton), + 110 => Some(chrono_tz::Tz::America__Eirunepe), + 111 => Some(chrono_tz::Tz::America__El_Salvador), + 112 => Some(chrono_tz::Tz::America__Ensenada), + 113 => Some(chrono_tz::Tz::America__Fort_Nelson), + 114 => Some(chrono_tz::Tz::America__Fort_Wayne), + 115 => Some(chrono_tz::Tz::America__Fortaleza), + 116 => Some(chrono_tz::Tz::America__Glace_Bay), + 117 => Some(chrono_tz::Tz::America__Godthab), + 118 => Some(chrono_tz::Tz::America__Goose_Bay), + 119 => Some(chrono_tz::Tz::America__Grand_Turk), + 120 => Some(chrono_tz::Tz::America__Grenada), + 121 => Some(chrono_tz::Tz::America__Guadeloupe), + 122 => Some(chrono_tz::Tz::America__Guatemala), + 123 => Some(chrono_tz::Tz::America__Guayaquil), + 124 => Some(chrono_tz::Tz::America__Guyana), + 125 => Some(chrono_tz::Tz::America__Halifax), + 126 => Some(chrono_tz::Tz::America__Havana), + 127 => Some(chrono_tz::Tz::America__Hermosillo), + 128 => Some(chrono_tz::Tz::America__Indiana__Indianapolis), + 129 => Some(chrono_tz::Tz::America__Indiana__Knox), + 130 => Some(chrono_tz::Tz::America__Indiana__Marengo), + 131 => Some(chrono_tz::Tz::America__Indiana__Petersburg), + 132 => Some(chrono_tz::Tz::America__Indiana__Tell_City), + 133 => Some(chrono_tz::Tz::America__Indiana__Vevay), + 134 => Some(chrono_tz::Tz::America__Indiana__Vincennes), + 135 => Some(chrono_tz::Tz::America__Indiana__Winamac), + 136 => Some(chrono_tz::Tz::America__Indianapolis), + 137 => Some(chrono_tz::Tz::America__Inuvik), + 138 => Some(chrono_tz::Tz::America__Iqaluit), + 139 => Some(chrono_tz::Tz::America__Jamaica), + 140 => Some(chrono_tz::Tz::America__Jujuy), + 141 => Some(chrono_tz::Tz::America__Juneau), + 142 => Some(chrono_tz::Tz::America__Kentucky__Louisville), + 143 => Some(chrono_tz::Tz::America__Kentucky__Monticello), + 144 => Some(chrono_tz::Tz::America__Knox_IN), + 145 => Some(chrono_tz::Tz::America__Kralendijk), + 146 => Some(chrono_tz::Tz::America__La_Paz), + 147 => Some(chrono_tz::Tz::America__Lima), + 148 => Some(chrono_tz::Tz::America__Los_Angeles), + 149 => Some(chrono_tz::Tz::America__Louisville), + 150 => Some(chrono_tz::Tz::America__Lower_Princes), + 151 => Some(chrono_tz::Tz::America__Maceio), + 152 => Some(chrono_tz::Tz::America__Managua), + 153 => Some(chrono_tz::Tz::America__Manaus), + 154 => Some(chrono_tz::Tz::America__Marigot), + 155 => Some(chrono_tz::Tz::America__Martinique), + 156 => Some(chrono_tz::Tz::America__Matamoros), + 157 => Some(chrono_tz::Tz::America__Mazatlan), + 158 => Some(chrono_tz::Tz::America__Mendoza), + 159 => Some(chrono_tz::Tz::America__Menominee), + 160 => Some(chrono_tz::Tz::America__Merida), + 161 => Some(chrono_tz::Tz::America__Metlakatla), + 162 => Some(chrono_tz::Tz::America__Mexico_City), + 163 => Some(chrono_tz::Tz::America__Miquelon), + 164 => Some(chrono_tz::Tz::America__Moncton), + 165 => Some(chrono_tz::Tz::America__Monterrey), + 166 => Some(chrono_tz::Tz::America__Montevideo), + 167 => Some(chrono_tz::Tz::America__Montreal), + 168 => Some(chrono_tz::Tz::America__Montserrat), + 169 => Some(chrono_tz::Tz::America__Nassau), + 170 => Some(chrono_tz::Tz::America__New_York), + 171 => Some(chrono_tz::Tz::America__Nipigon), + 172 => Some(chrono_tz::Tz::America__Nome), + 173 => Some(chrono_tz::Tz::America__Noronha), + 174 => Some(chrono_tz::Tz::America__North_Dakota__Beulah), + 175 => Some(chrono_tz::Tz::America__North_Dakota__Center), + 176 => Some(chrono_tz::Tz::America__North_Dakota__New_Salem), + 177 => Some(chrono_tz::Tz::America__Nuuk), + 178 => Some(chrono_tz::Tz::America__Ojinaga), + 179 => Some(chrono_tz::Tz::America__Panama), + 180 => Some(chrono_tz::Tz::America__Pangnirtung), + 181 => Some(chrono_tz::Tz::America__Paramaribo), + 182 => Some(chrono_tz::Tz::America__Phoenix), + 183 => Some(chrono_tz::Tz::America__PortauPrince), + 184 => Some(chrono_tz::Tz::America__Port_of_Spain), + 185 => Some(chrono_tz::Tz::America__Porto_Acre), + 186 => Some(chrono_tz::Tz::America__Porto_Velho), + 187 => Some(chrono_tz::Tz::America__Puerto_Rico), + 188 => Some(chrono_tz::Tz::America__Punta_Arenas), + 189 => Some(chrono_tz::Tz::America__Rainy_River), + 190 => Some(chrono_tz::Tz::America__Rankin_Inlet), + 191 => Some(chrono_tz::Tz::America__Recife), + 192 => Some(chrono_tz::Tz::America__Regina), + 193 => Some(chrono_tz::Tz::America__Resolute), + 194 => Some(chrono_tz::Tz::America__Rio_Branco), + 195 => Some(chrono_tz::Tz::America__Rosario), + 196 => Some(chrono_tz::Tz::America__Santa_Isabel), + 197 => Some(chrono_tz::Tz::America__Santarem), + 198 => Some(chrono_tz::Tz::America__Santiago), + 199 => Some(chrono_tz::Tz::America__Santo_Domingo), + 200 => Some(chrono_tz::Tz::America__Sao_Paulo), + 201 => Some(chrono_tz::Tz::America__Scoresbysund), + 202 => Some(chrono_tz::Tz::America__Shiprock), + 203 => Some(chrono_tz::Tz::America__Sitka), + 204 => Some(chrono_tz::Tz::America__St_Barthelemy), + 205 => Some(chrono_tz::Tz::America__St_Johns), + 206 => Some(chrono_tz::Tz::America__St_Kitts), + 207 => Some(chrono_tz::Tz::America__St_Lucia), + 208 => Some(chrono_tz::Tz::America__St_Thomas), + 209 => Some(chrono_tz::Tz::America__St_Vincent), + 210 => Some(chrono_tz::Tz::America__Swift_Current), + 211 => Some(chrono_tz::Tz::America__Tegucigalpa), + 212 => Some(chrono_tz::Tz::America__Thule), + 213 => Some(chrono_tz::Tz::America__Thunder_Bay), + 214 => Some(chrono_tz::Tz::America__Tijuana), + 215 => Some(chrono_tz::Tz::America__Toronto), + 216 => Some(chrono_tz::Tz::America__Tortola), + 217 => Some(chrono_tz::Tz::America__Vancouver), + 218 => Some(chrono_tz::Tz::America__Virgin), + 219 => Some(chrono_tz::Tz::America__Whitehorse), + 220 => Some(chrono_tz::Tz::America__Winnipeg), + 221 => Some(chrono_tz::Tz::America__Yakutat), + 222 => Some(chrono_tz::Tz::America__Yellowknife), + 223 => Some(chrono_tz::Tz::Antarctica__Casey), + 224 => Some(chrono_tz::Tz::Antarctica__Davis), + 225 => Some(chrono_tz::Tz::Antarctica__DumontDUrville), + 226 => Some(chrono_tz::Tz::Antarctica__Macquarie), + 227 => Some(chrono_tz::Tz::Antarctica__Mawson), + 228 => Some(chrono_tz::Tz::Antarctica__McMurdo), + 229 => Some(chrono_tz::Tz::Antarctica__Palmer), + 230 => Some(chrono_tz::Tz::Antarctica__Rothera), + 231 => Some(chrono_tz::Tz::Antarctica__South_Pole), + 232 => Some(chrono_tz::Tz::Antarctica__Syowa), + 233 => Some(chrono_tz::Tz::Antarctica__Troll), + 234 => Some(chrono_tz::Tz::Antarctica__Vostok), + 235 => Some(chrono_tz::Tz::Arctic__Longyearbyen), + 236 => Some(chrono_tz::Tz::Asia__Aden), + 237 => Some(chrono_tz::Tz::Asia__Almaty), + 238 => Some(chrono_tz::Tz::Asia__Amman), + 239 => Some(chrono_tz::Tz::Asia__Anadyr), + 240 => Some(chrono_tz::Tz::Asia__Aqtau), + 241 => Some(chrono_tz::Tz::Asia__Aqtobe), + 242 => Some(chrono_tz::Tz::Asia__Ashgabat), + 243 => Some(chrono_tz::Tz::Asia__Ashkhabad), + 244 => Some(chrono_tz::Tz::Asia__Atyrau), + 245 => Some(chrono_tz::Tz::Asia__Baghdad), + 246 => Some(chrono_tz::Tz::Asia__Bahrain), + 247 => Some(chrono_tz::Tz::Asia__Baku), + 248 => Some(chrono_tz::Tz::Asia__Bangkok), + 249 => Some(chrono_tz::Tz::Asia__Barnaul), + 250 => Some(chrono_tz::Tz::Asia__Beirut), + 251 => Some(chrono_tz::Tz::Asia__Bishkek), + 252 => Some(chrono_tz::Tz::Asia__Brunei), + 253 => Some(chrono_tz::Tz::Asia__Calcutta), + 254 => Some(chrono_tz::Tz::Asia__Chita), + 255 => Some(chrono_tz::Tz::Asia__Choibalsan), + 256 => Some(chrono_tz::Tz::Asia__Chongqing), + 257 => Some(chrono_tz::Tz::Asia__Chungking), + 258 => Some(chrono_tz::Tz::Asia__Colombo), + 259 => Some(chrono_tz::Tz::Asia__Dacca), + 260 => Some(chrono_tz::Tz::Asia__Damascus), + 261 => Some(chrono_tz::Tz::Asia__Dhaka), + 262 => Some(chrono_tz::Tz::Asia__Dili), + 263 => Some(chrono_tz::Tz::Asia__Dubai), + 264 => Some(chrono_tz::Tz::Asia__Dushanbe), + 265 => Some(chrono_tz::Tz::Asia__Famagusta), + 266 => Some(chrono_tz::Tz::Asia__Gaza), + 267 => Some(chrono_tz::Tz::Asia__Harbin), + 268 => Some(chrono_tz::Tz::Asia__Hebron), + 269 => Some(chrono_tz::Tz::Asia__Ho_Chi_Minh), + 270 => Some(chrono_tz::Tz::Asia__Hong_Kong), + 271 => Some(chrono_tz::Tz::Asia__Hovd), + 272 => Some(chrono_tz::Tz::Asia__Irkutsk), + 273 => Some(chrono_tz::Tz::Asia__Istanbul), + 274 => Some(chrono_tz::Tz::Asia__Jakarta), + 275 => Some(chrono_tz::Tz::Asia__Jayapura), + 276 => Some(chrono_tz::Tz::Asia__Jerusalem), + 277 => Some(chrono_tz::Tz::Asia__Kabul), + 278 => Some(chrono_tz::Tz::Asia__Kamchatka), + 279 => Some(chrono_tz::Tz::Asia__Karachi), + 280 => Some(chrono_tz::Tz::Asia__Kashgar), + 281 => Some(chrono_tz::Tz::Asia__Kathmandu), + 282 => Some(chrono_tz::Tz::Asia__Katmandu), + 283 => Some(chrono_tz::Tz::Asia__Khandyga), + 284 => Some(chrono_tz::Tz::Asia__Kolkata), + 285 => Some(chrono_tz::Tz::Asia__Krasnoyarsk), + 286 => Some(chrono_tz::Tz::Asia__Kuala_Lumpur), + 287 => Some(chrono_tz::Tz::Asia__Kuching), + 288 => Some(chrono_tz::Tz::Asia__Kuwait), + 289 => Some(chrono_tz::Tz::Asia__Macao), + 290 => Some(chrono_tz::Tz::Asia__Macau), + 291 => Some(chrono_tz::Tz::Asia__Magadan), + 292 => Some(chrono_tz::Tz::Asia__Makassar), + 293 => Some(chrono_tz::Tz::Asia__Manila), + 294 => Some(chrono_tz::Tz::Asia__Muscat), + 295 => Some(chrono_tz::Tz::Asia__Nicosia), + 296 => Some(chrono_tz::Tz::Asia__Novokuznetsk), + 297 => Some(chrono_tz::Tz::Asia__Novosibirsk), + 298 => Some(chrono_tz::Tz::Asia__Omsk), + 299 => Some(chrono_tz::Tz::Asia__Oral), + 300 => Some(chrono_tz::Tz::Asia__Phnom_Penh), + 301 => Some(chrono_tz::Tz::Asia__Pontianak), + 302 => Some(chrono_tz::Tz::Asia__Pyongyang), + 303 => Some(chrono_tz::Tz::Asia__Qatar), + 304 => Some(chrono_tz::Tz::Asia__Qostanay), + 305 => Some(chrono_tz::Tz::Asia__Qyzylorda), + 306 => Some(chrono_tz::Tz::Asia__Rangoon), + 307 => Some(chrono_tz::Tz::Asia__Riyadh), + 308 => Some(chrono_tz::Tz::Asia__Saigon), + 309 => Some(chrono_tz::Tz::Asia__Sakhalin), + 310 => Some(chrono_tz::Tz::Asia__Samarkand), + 311 => Some(chrono_tz::Tz::Asia__Seoul), + 312 => Some(chrono_tz::Tz::Asia__Shanghai), + 313 => Some(chrono_tz::Tz::Asia__Singapore), + 314 => Some(chrono_tz::Tz::Asia__Srednekolymsk), + 315 => Some(chrono_tz::Tz::Asia__Taipei), + 316 => Some(chrono_tz::Tz::Asia__Tashkent), + 317 => Some(chrono_tz::Tz::Asia__Tbilisi), + 318 => Some(chrono_tz::Tz::Asia__Tehran), + 319 => Some(chrono_tz::Tz::Asia__Tel_Aviv), + 320 => Some(chrono_tz::Tz::Asia__Thimbu), + 321 => Some(chrono_tz::Tz::Asia__Thimphu), + 322 => Some(chrono_tz::Tz::Asia__Tokyo), + 323 => Some(chrono_tz::Tz::Asia__Tomsk), + 324 => Some(chrono_tz::Tz::Asia__Ujung_Pandang), + 325 => Some(chrono_tz::Tz::Asia__Ulaanbaatar), + 326 => Some(chrono_tz::Tz::Asia__Ulan_Bator), + 327 => Some(chrono_tz::Tz::Asia__Urumqi), + 328 => Some(chrono_tz::Tz::Asia__UstNera), + 329 => Some(chrono_tz::Tz::Asia__Vientiane), + 330 => Some(chrono_tz::Tz::Asia__Vladivostok), + 331 => Some(chrono_tz::Tz::Asia__Yakutsk), + 332 => Some(chrono_tz::Tz::Asia__Yangon), + 333 => Some(chrono_tz::Tz::Asia__Yekaterinburg), + 334 => Some(chrono_tz::Tz::Asia__Yerevan), + 335 => Some(chrono_tz::Tz::Atlantic__Azores), + 336 => Some(chrono_tz::Tz::Atlantic__Bermuda), + 337 => Some(chrono_tz::Tz::Atlantic__Canary), + 338 => Some(chrono_tz::Tz::Atlantic__Cape_Verde), + 339 => Some(chrono_tz::Tz::Atlantic__Faeroe), + 340 => Some(chrono_tz::Tz::Atlantic__Faroe), + 341 => Some(chrono_tz::Tz::Atlantic__Jan_Mayen), + 342 => Some(chrono_tz::Tz::Atlantic__Madeira), + 343 => Some(chrono_tz::Tz::Atlantic__Reykjavik), + 344 => Some(chrono_tz::Tz::Atlantic__South_Georgia), + 345 => Some(chrono_tz::Tz::Atlantic__St_Helena), + 346 => Some(chrono_tz::Tz::Atlantic__Stanley), + 347 => Some(chrono_tz::Tz::Australia__ACT), + 348 => Some(chrono_tz::Tz::Australia__Adelaide), + 349 => Some(chrono_tz::Tz::Australia__Brisbane), + 350 => Some(chrono_tz::Tz::Australia__Broken_Hill), + 351 => Some(chrono_tz::Tz::Australia__Canberra), + 352 => Some(chrono_tz::Tz::Australia__Currie), + 353 => Some(chrono_tz::Tz::Australia__Darwin), + 354 => Some(chrono_tz::Tz::Australia__Eucla), + 355 => Some(chrono_tz::Tz::Australia__Hobart), + 356 => Some(chrono_tz::Tz::Australia__LHI), + 357 => Some(chrono_tz::Tz::Australia__Lindeman), + 358 => Some(chrono_tz::Tz::Australia__Lord_Howe), + 359 => Some(chrono_tz::Tz::Australia__Melbourne), + 360 => Some(chrono_tz::Tz::Australia__NSW), + 361 => Some(chrono_tz::Tz::Australia__North), + 362 => Some(chrono_tz::Tz::Australia__Perth), + 363 => Some(chrono_tz::Tz::Australia__Queensland), + 364 => Some(chrono_tz::Tz::Australia__South), + 365 => Some(chrono_tz::Tz::Australia__Sydney), + 366 => Some(chrono_tz::Tz::Australia__Tasmania), + 367 => Some(chrono_tz::Tz::Australia__Victoria), + 368 => Some(chrono_tz::Tz::Australia__West), + 369 => Some(chrono_tz::Tz::Australia__Yancowinna), + 370 => Some(chrono_tz::Tz::Brazil__Acre), + 371 => Some(chrono_tz::Tz::Brazil__DeNoronha), + 372 => Some(chrono_tz::Tz::Brazil__East), + 373 => Some(chrono_tz::Tz::Brazil__West), + 374 => Some(chrono_tz::Tz::CET), + 375 => Some(chrono_tz::Tz::CST6CDT), + 376 => Some(chrono_tz::Tz::Canada__Atlantic), + 377 => Some(chrono_tz::Tz::Canada__Central), + 378 => Some(chrono_tz::Tz::Canada__Eastern), + 379 => Some(chrono_tz::Tz::Canada__Mountain), + 380 => Some(chrono_tz::Tz::Canada__Newfoundland), + 381 => Some(chrono_tz::Tz::Canada__Pacific), + 382 => Some(chrono_tz::Tz::Canada__Saskatchewan), + 383 => Some(chrono_tz::Tz::Canada__Yukon), + 384 => Some(chrono_tz::Tz::Chile__Continental), + 385 => Some(chrono_tz::Tz::Chile__EasterIsland), + 386 => Some(chrono_tz::Tz::Cuba), + 387 => Some(chrono_tz::Tz::EET), + 388 => Some(chrono_tz::Tz::EST), + 389 => Some(chrono_tz::Tz::EST5EDT), + 390 => Some(chrono_tz::Tz::Egypt), + 391 => Some(chrono_tz::Tz::Eire), + 392 => Some(chrono_tz::Tz::Etc__GMT), + 393 => Some(chrono_tz::Tz::Etc__GMTPlus0), + 394 => Some(chrono_tz::Tz::Etc__GMTPlus1), + 395 => Some(chrono_tz::Tz::Etc__GMTPlus10), + 396 => Some(chrono_tz::Tz::Etc__GMTPlus11), + 397 => Some(chrono_tz::Tz::Etc__GMTPlus12), + 398 => Some(chrono_tz::Tz::Etc__GMTPlus2), + 399 => Some(chrono_tz::Tz::Etc__GMTPlus3), + 400 => Some(chrono_tz::Tz::Etc__GMTPlus4), + 401 => Some(chrono_tz::Tz::Etc__GMTPlus5), + 402 => Some(chrono_tz::Tz::Etc__GMTPlus6), + 403 => Some(chrono_tz::Tz::Etc__GMTPlus7), + 404 => Some(chrono_tz::Tz::Etc__GMTPlus8), + 405 => Some(chrono_tz::Tz::Etc__GMTPlus9), + 406 => Some(chrono_tz::Tz::Etc__GMTMinus0), + 407 => Some(chrono_tz::Tz::Etc__GMTMinus1), + 408 => Some(chrono_tz::Tz::Etc__GMTMinus10), + 409 => Some(chrono_tz::Tz::Etc__GMTMinus11), + 410 => Some(chrono_tz::Tz::Etc__GMTMinus12), + 411 => Some(chrono_tz::Tz::Etc__GMTMinus13), + 412 => Some(chrono_tz::Tz::Etc__GMTMinus14), + 413 => Some(chrono_tz::Tz::Etc__GMTMinus2), + 414 => Some(chrono_tz::Tz::Etc__GMTMinus3), + 415 => Some(chrono_tz::Tz::Etc__GMTMinus4), + 416 => Some(chrono_tz::Tz::Etc__GMTMinus5), + 417 => Some(chrono_tz::Tz::Etc__GMTMinus6), + 418 => Some(chrono_tz::Tz::Etc__GMTMinus7), + 419 => Some(chrono_tz::Tz::Etc__GMTMinus8), + 420 => Some(chrono_tz::Tz::Etc__GMTMinus9), + 421 => Some(chrono_tz::Tz::Etc__GMT0), + 422 => Some(chrono_tz::Tz::Etc__Greenwich), + 423 => Some(chrono_tz::Tz::Etc__UCT), + 424 => Some(chrono_tz::Tz::Etc__UTC), + 425 => Some(chrono_tz::Tz::Etc__Universal), + 426 => Some(chrono_tz::Tz::Etc__Zulu), + 427 => Some(chrono_tz::Tz::Europe__Amsterdam), + 428 => Some(chrono_tz::Tz::Europe__Andorra), + 429 => Some(chrono_tz::Tz::Europe__Astrakhan), + 430 => Some(chrono_tz::Tz::Europe__Athens), + 431 => Some(chrono_tz::Tz::Europe__Belfast), + 432 => Some(chrono_tz::Tz::Europe__Belgrade), + 433 => Some(chrono_tz::Tz::Europe__Berlin), + 434 => Some(chrono_tz::Tz::Europe__Bratislava), + 435 => Some(chrono_tz::Tz::Europe__Brussels), + 436 => Some(chrono_tz::Tz::Europe__Bucharest), + 437 => Some(chrono_tz::Tz::Europe__Budapest), + 438 => Some(chrono_tz::Tz::Europe__Busingen), + 439 => Some(chrono_tz::Tz::Europe__Chisinau), + 440 => Some(chrono_tz::Tz::Europe__Copenhagen), + 441 => Some(chrono_tz::Tz::Europe__Dublin), + 442 => Some(chrono_tz::Tz::Europe__Gibraltar), + 443 => Some(chrono_tz::Tz::Europe__Guernsey), + 444 => Some(chrono_tz::Tz::Europe__Helsinki), + 445 => Some(chrono_tz::Tz::Europe__Isle_of_Man), + 446 => Some(chrono_tz::Tz::Europe__Istanbul), + 447 => Some(chrono_tz::Tz::Europe__Jersey), + 448 => Some(chrono_tz::Tz::Europe__Kaliningrad), + 449 => Some(chrono_tz::Tz::Europe__Kiev), + 450 => Some(chrono_tz::Tz::Europe__Kirov), + 451 => Some(chrono_tz::Tz::Europe__Kyiv), + 452 => Some(chrono_tz::Tz::Europe__Lisbon), + 453 => Some(chrono_tz::Tz::Europe__Ljubljana), + 454 => Some(chrono_tz::Tz::Europe__London), + 455 => Some(chrono_tz::Tz::Europe__Luxembourg), + 456 => Some(chrono_tz::Tz::Europe__Madrid), + 457 => Some(chrono_tz::Tz::Europe__Malta), + 458 => Some(chrono_tz::Tz::Europe__Mariehamn), + 459 => Some(chrono_tz::Tz::Europe__Minsk), + 460 => Some(chrono_tz::Tz::Europe__Monaco), + 461 => Some(chrono_tz::Tz::Europe__Moscow), + 462 => Some(chrono_tz::Tz::Europe__Nicosia), + 463 => Some(chrono_tz::Tz::Europe__Oslo), + 464 => Some(chrono_tz::Tz::Europe__Paris), + 465 => Some(chrono_tz::Tz::Europe__Podgorica), + 466 => Some(chrono_tz::Tz::Europe__Prague), + 467 => Some(chrono_tz::Tz::Europe__Riga), + 468 => Some(chrono_tz::Tz::Europe__Rome), + 469 => Some(chrono_tz::Tz::Europe__Samara), + 470 => Some(chrono_tz::Tz::Europe__San_Marino), + 471 => Some(chrono_tz::Tz::Europe__Sarajevo), + 472 => Some(chrono_tz::Tz::Europe__Saratov), + 473 => Some(chrono_tz::Tz::Europe__Simferopol), + 474 => Some(chrono_tz::Tz::Europe__Skopje), + 475 => Some(chrono_tz::Tz::Europe__Sofia), + 476 => Some(chrono_tz::Tz::Europe__Stockholm), + 477 => Some(chrono_tz::Tz::Europe__Tallinn), + 478 => Some(chrono_tz::Tz::Europe__Tirane), + 479 => Some(chrono_tz::Tz::Europe__Tiraspol), + 480 => Some(chrono_tz::Tz::Europe__Ulyanovsk), + 481 => Some(chrono_tz::Tz::Europe__Uzhgorod), + 482 => Some(chrono_tz::Tz::Europe__Vaduz), + 483 => Some(chrono_tz::Tz::Europe__Vatican), + 484 => Some(chrono_tz::Tz::Europe__Vienna), + 485 => Some(chrono_tz::Tz::Europe__Vilnius), + 486 => Some(chrono_tz::Tz::Europe__Volgograd), + 487 => Some(chrono_tz::Tz::Europe__Warsaw), + 488 => Some(chrono_tz::Tz::Europe__Zagreb), + 489 => Some(chrono_tz::Tz::Europe__Zaporozhye), + 490 => Some(chrono_tz::Tz::Europe__Zurich), + 491 => Some(chrono_tz::Tz::GB), + 492 => Some(chrono_tz::Tz::GBEire), + 493 => Some(chrono_tz::Tz::GMT), + 494 => Some(chrono_tz::Tz::GMTPlus0), + 495 => Some(chrono_tz::Tz::GMTMinus0), + 496 => Some(chrono_tz::Tz::GMT0), + 497 => Some(chrono_tz::Tz::Greenwich), + 498 => Some(chrono_tz::Tz::HST), + 499 => Some(chrono_tz::Tz::Hongkong), + 500 => Some(chrono_tz::Tz::Iceland), + 501 => Some(chrono_tz::Tz::Indian__Antananarivo), + 502 => Some(chrono_tz::Tz::Indian__Chagos), + 503 => Some(chrono_tz::Tz::Indian__Christmas), + 504 => Some(chrono_tz::Tz::Indian__Cocos), + 505 => Some(chrono_tz::Tz::Indian__Comoro), + 506 => Some(chrono_tz::Tz::Indian__Kerguelen), + 507 => Some(chrono_tz::Tz::Indian__Mahe), + 508 => Some(chrono_tz::Tz::Indian__Maldives), + 509 => Some(chrono_tz::Tz::Indian__Mauritius), + 510 => Some(chrono_tz::Tz::Indian__Mayotte), + 511 => Some(chrono_tz::Tz::Indian__Reunion), + 512 => Some(chrono_tz::Tz::Iran), + 513 => Some(chrono_tz::Tz::Israel), + 514 => Some(chrono_tz::Tz::Jamaica), + 515 => Some(chrono_tz::Tz::Japan), + 516 => Some(chrono_tz::Tz::Kwajalein), + 517 => Some(chrono_tz::Tz::Libya), + 518 => Some(chrono_tz::Tz::MET), + 519 => Some(chrono_tz::Tz::MST), + 520 => Some(chrono_tz::Tz::MST7MDT), + 521 => Some(chrono_tz::Tz::Mexico__BajaNorte), + 522 => Some(chrono_tz::Tz::Mexico__BajaSur), + 523 => Some(chrono_tz::Tz::Mexico__General), + 524 => Some(chrono_tz::Tz::NZ), + 525 => Some(chrono_tz::Tz::NZCHAT), + 526 => Some(chrono_tz::Tz::Navajo), + 527 => Some(chrono_tz::Tz::PRC), + 528 => Some(chrono_tz::Tz::PST8PDT), + 529 => Some(chrono_tz::Tz::Pacific__Apia), + 530 => Some(chrono_tz::Tz::Pacific__Auckland), + 531 => Some(chrono_tz::Tz::Pacific__Bougainville), + 532 => Some(chrono_tz::Tz::Pacific__Chatham), + 533 => Some(chrono_tz::Tz::Pacific__Chuuk), + 534 => Some(chrono_tz::Tz::Pacific__Easter), + 535 => Some(chrono_tz::Tz::Pacific__Efate), + 536 => Some(chrono_tz::Tz::Pacific__Enderbury), + 537 => Some(chrono_tz::Tz::Pacific__Fakaofo), + 538 => Some(chrono_tz::Tz::Pacific__Fiji), + 539 => Some(chrono_tz::Tz::Pacific__Funafuti), + 540 => Some(chrono_tz::Tz::Pacific__Galapagos), + 541 => Some(chrono_tz::Tz::Pacific__Gambier), + 542 => Some(chrono_tz::Tz::Pacific__Guadalcanal), + 543 => Some(chrono_tz::Tz::Pacific__Guam), + 544 => Some(chrono_tz::Tz::Pacific__Honolulu), + 545 => Some(chrono_tz::Tz::Pacific__Johnston), + 546 => Some(chrono_tz::Tz::Pacific__Kanton), + 547 => Some(chrono_tz::Tz::Pacific__Kiritimati), + 548 => Some(chrono_tz::Tz::Pacific__Kosrae), + 549 => Some(chrono_tz::Tz::Pacific__Kwajalein), + 550 => Some(chrono_tz::Tz::Pacific__Majuro), + 551 => Some(chrono_tz::Tz::Pacific__Marquesas), + 552 => Some(chrono_tz::Tz::Pacific__Midway), + 553 => Some(chrono_tz::Tz::Pacific__Nauru), + 554 => Some(chrono_tz::Tz::Pacific__Niue), + 555 => Some(chrono_tz::Tz::Pacific__Norfolk), + 556 => Some(chrono_tz::Tz::Pacific__Noumea), + 557 => Some(chrono_tz::Tz::Pacific__Pago_Pago), + 558 => Some(chrono_tz::Tz::Pacific__Palau), + 559 => Some(chrono_tz::Tz::Pacific__Pitcairn), + 560 => Some(chrono_tz::Tz::Pacific__Pohnpei), + 561 => Some(chrono_tz::Tz::Pacific__Ponape), + 562 => Some(chrono_tz::Tz::Pacific__Port_Moresby), + 563 => Some(chrono_tz::Tz::Pacific__Rarotonga), + 564 => Some(chrono_tz::Tz::Pacific__Saipan), + 565 => Some(chrono_tz::Tz::Pacific__Samoa), + 566 => Some(chrono_tz::Tz::Pacific__Tahiti), + 567 => Some(chrono_tz::Tz::Pacific__Tarawa), + 568 => Some(chrono_tz::Tz::Pacific__Tongatapu), + 569 => Some(chrono_tz::Tz::Pacific__Truk), + 570 => Some(chrono_tz::Tz::Pacific__Wake), + 571 => Some(chrono_tz::Tz::Pacific__Wallis), + 572 => Some(chrono_tz::Tz::Pacific__Yap), + 573 => Some(chrono_tz::Tz::Poland), + 574 => Some(chrono_tz::Tz::Portugal), + 575 => Some(chrono_tz::Tz::ROC), + 576 => Some(chrono_tz::Tz::ROK), + 577 => Some(chrono_tz::Tz::Singapore), + 578 => Some(chrono_tz::Tz::Turkey), + 579 => Some(chrono_tz::Tz::UCT), + 580 => Some(chrono_tz::Tz::US__Alaska), + 581 => Some(chrono_tz::Tz::US__Aleutian), + 582 => Some(chrono_tz::Tz::US__Arizona), + 583 => Some(chrono_tz::Tz::US__Central), + 584 => Some(chrono_tz::Tz::US__EastIndiana), + 585 => Some(chrono_tz::Tz::US__Eastern), + 586 => Some(chrono_tz::Tz::US__Hawaii), + 587 => Some(chrono_tz::Tz::US__IndianaStarke), + 588 => Some(chrono_tz::Tz::US__Michigan), + 589 => Some(chrono_tz::Tz::US__Mountain), + 590 => Some(chrono_tz::Tz::US__Pacific), + 591 => Some(chrono_tz::Tz::US__Samoa), + 592 => Some(chrono_tz::Tz::UTC), + 593 => Some(chrono_tz::Tz::Universal), + 594 => Some(chrono_tz::Tz::WSU), + 595 => Some(chrono_tz::Tz::WET), + 596 => Some(chrono_tz::Tz::Zulu), + _ => None, + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_tz() { + for (zone_name, tz) in [ + ("America/New_York", chrono_tz::Tz::America__New_York), + ( + "(GMT+10:00) Canberra, Melbourne, Sydney", + chrono_tz::Tz::Australia__Sydney, + ), + ( + "(UTC-05:00) Eastern Time (US & Canada)", + chrono_tz::Tz::America__New_York, + ), + ("(GMT +01:00)", chrono_tz::Tz::Etc__GMTMinus1), + ("(GMT+01.00)", chrono_tz::Tz::Etc__GMTMinus1), + ("(UTC-03:00)", chrono_tz::Tz::Etc__GMTPlus3), + ("/Europe/Stockholm", chrono_tz::Tz::Europe__Stockholm), + ( + "/softwarestudio.org/Olson_20011030_5/America/Chicago", + chrono_tz::Tz::America__Chicago, + ), + ( + "/freeassociation.sourceforge.net/Tzfile/Europe/Ljubljana", + chrono_tz::Tz::Europe__Ljubljana, + ), + ( + "/freeassociation.sourceforge.net/Tzfile/SystemV/EST5EDT", + chrono_tz::Tz::EST5EDT, + ), + ] { + assert_eq!(Tz::from_str(zone_name).expect(zone_name), Tz::Tz(tz)); + } + } + + #[test] + fn test_tz_id_rountrip() { + for tz in [ + Tz::Tz(chrono_tz::Tz::America__New_York), + Tz::Tz(chrono_tz::Tz::Europe__Ljubljana), + Tz::Tz(chrono_tz::Tz::UTC), + Tz::Floating, + Tz::Fixed(chrono::FixedOffset::east_opt(14 * 3600).unwrap()), + Tz::Fixed(chrono::FixedOffset::west_opt(14 * 3600).unwrap()), + Tz::Fixed(chrono::FixedOffset::east_opt(3600).unwrap()), + Tz::Fixed(chrono::FixedOffset::west_opt(3600).unwrap()), + ] { + let id = tz.as_id(); + let tz_from_id = Tz::from_id(id).unwrap(); + assert_eq!(tz, tz_from_id, "failed for {tz:?}"); + } + } +} diff --git a/vendor/calcard/src/common/tokenizer.rs b/vendor/calcard/src/common/tokenizer.rs new file mode 100644 index 00000000..f71b8956 --- /dev/null +++ b/vendor/calcard/src/common/tokenizer.rs @@ -0,0 +1,791 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{Parser, common::IanaParse}; +use std::borrow::Cow; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Token<'x> { + pub(crate) text: Cow<'x, [u8]>, + pub(crate) start: usize, + pub(crate) end: usize, + pub(crate) stop_char: StopChar, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum StopChar { + Colon, + Semicolon, + Comma, + Equal, + Dot, + Lf, +} + +impl<'x> Parser<'x> { + pub(crate) fn expect_iana_token(&mut self) { + self.stop_colon = true; + self.stop_semicolon = true; + self.stop_comma = true; + self.stop_equal = true; + self.unfold_qp = false; + self.unfold_b64 = false; + self.unquote = true; + self.stop_dot = false; + self.skip_ws = true; + self.strip_ctl = false; + } + + pub(crate) fn expect_single_value(&mut self) { + self.stop_colon = false; + self.stop_comma = false; + self.stop_equal = false; + self.stop_semicolon = false; + self.unquote = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.stop_dot = false; + self.skip_ws = false; + self.strip_ctl = false; + } + + pub(crate) fn expect_multi_value_comma(&mut self) { + self.stop_colon = false; + self.stop_comma = true; + self.stop_equal = false; + self.stop_semicolon = false; + self.unquote = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.stop_dot = false; + self.skip_ws = true; + self.strip_ctl = false; + } + + pub(crate) fn expect_multi_value_semicolon(&mut self) { + self.stop_colon = false; + self.stop_comma = false; + self.stop_equal = false; + self.stop_semicolon = true; + self.unquote = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.stop_dot = false; + self.skip_ws = false; + self.strip_ctl = false; + } + + pub(crate) fn expect_multi_value_semicolon_and_comma(&mut self) { + self.stop_colon = false; + self.stop_comma = true; + self.stop_equal = false; + self.stop_semicolon = true; + self.unquote = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.stop_dot = false; + self.skip_ws = false; + self.strip_ctl = false; + } + + pub(crate) fn expect_param_value(&mut self) { + self.stop_colon = true; + self.stop_semicolon = true; + self.stop_comma = true; + self.stop_equal = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.unquote = true; + self.stop_dot = false; + self.skip_ws = true; + self.strip_ctl = true; + } + + pub(crate) fn expect_rrule_value(&mut self) { + self.stop_colon = true; + self.stop_comma = true; + self.stop_equal = true; + self.stop_semicolon = true; + self.unquote = false; + self.unfold_qp = false; + self.unfold_b64 = false; + self.stop_dot = false; + self.skip_ws = true; + self.strip_ctl = false; + } + + fn try_unfold(&mut self) -> bool { + if let Some((_, next)) = self.iter.peek() + && (**next == b' ' || **next == b'\t') + { + self.iter.next(); + return true; + } + false + } + + fn is_base64_continuation(&self, idx: usize) -> bool { + let mut has_data = false; + + for ch in self.input.get(idx + 1..).unwrap_or_default() { + match ch { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' | b'=' => has_data = true, + b'\r' => {} + b'\n' => break, + _ => return false, + } + } + + has_data + } + + pub(crate) fn token(&mut self) -> Option> { + let mut offset_start = usize::MAX; + let mut offset_end = usize::MAX; + let mut last_idx = 0; + let mut in_quote = false; + let stop_char; + let mut buf: Vec = vec![]; + let mut skipped_ws = usize::MAX; + + 'outer: loop { + let (idx, ch) = if let Some(next) = self.iter.next() { + next + } else if offset_start != usize::MAX { + stop_char = StopChar::Lf; + break; + } else { + return None; + }; + last_idx = idx; + + match ch { + b' ' | b'\t' => { + // Ignore leading and trailing whitespace (unless in a quoted string) + if in_quote || !self.skip_ws || buf.last().is_some_and(|lch| lch != ch) { + if offset_start == usize::MAX { + offset_start = idx; + } + offset_end = idx; + skipped_ws = usize::MAX; + + if !buf.is_empty() { + buf.push(*ch); + } + } else if skipped_ws == usize::MAX { + skipped_ws = idx; + } + } + b'\r' => {} + b'\n' => { + if self.unfold_qp + && buf.last().or_else(|| self.input.get(offset_end)).copied() == Some(b'=') + { + offset_end = idx; + + if !buf.is_empty() { + buf.push(*ch); + } + } else if self.try_unfold() { + if buf.is_empty() && offset_start != usize::MAX { + buf.extend_from_slice(&self.input[offset_start..=offset_end]); + } + + if skipped_ws != usize::MAX && !buf.is_empty() { + buf.extend( + self.input[skipped_ws..idx] + .iter() + .filter(|ch| matches!(ch, b' ' | b'\t')), + ); + } + skipped_ws = usize::MAX; + } else if self.unfold_b64 && self.is_base64_continuation(idx) { + if buf.is_empty() && offset_start != usize::MAX { + buf.extend_from_slice(&self.input[offset_start..=offset_end]); + } + skipped_ws = usize::MAX; + } else { + stop_char = StopChar::Lf; + break; + } + } + b'\\' => { + let mut next_ch = b'\\'; + let mut next_offset_end = idx; + while let Some((idx, ch)) = self.iter.next() { + match ch { + b'\t' | b'\r' => {} + b'\n' => { + if self.try_unfold() { + if let Some((idx, ch)) = self.iter.next() { + next_ch = *ch; + next_offset_end = idx; + break; + } + } else { + stop_char = StopChar::Lf; + offset_end = idx - 1; + break 'outer; + } + } + _ => { + next_ch = *ch; + next_offset_end = idx; + break; + } + } + } + if offset_start != usize::MAX { + if buf.is_empty() { + buf.extend_from_slice(&self.input[offset_start..=offset_end]); + } + + if skipped_ws != usize::MAX { + buf.extend( + self.input[skipped_ws..idx] + .iter() + .filter(|ch| matches!(ch, b' ' | b'\t')), + ); + } + } else { + offset_start = next_offset_end; + } + skipped_ws = usize::MAX; + buf.push(match next_ch { + b'n' | b'N' => b'\n', + b'r' | b'R' => b'\r', + _ => next_ch, + }); + offset_end = next_offset_end; + } + b'"' if self.unquote => { + in_quote = !in_quote; + } + b':' if !in_quote && self.stop_colon => { + stop_char = StopChar::Colon; + break; + } + b';' if !in_quote && self.stop_semicolon => { + stop_char = StopChar::Semicolon; + break; + } + b',' if !in_quote && self.stop_comma => { + stop_char = StopChar::Comma; + break; + } + b'=' if !in_quote && self.stop_equal => { + stop_char = StopChar::Equal; + break; + } + b'.' if !in_quote && self.stop_dot => { + stop_char = StopChar::Dot; + break; + } + 0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F if self.strip_ctl => { + if buf.is_empty() && offset_start != usize::MAX { + buf.extend_from_slice(&self.input[offset_start..=offset_end]); + } + } + _ => { + if offset_start == usize::MAX { + offset_start = idx; + } + offset_end = idx; + skipped_ws = usize::MAX; + + if !buf.is_empty() { + buf.push(*ch); + } + } + } + } + + if offset_start != usize::MAX { + self.last_token_end = offset_end; + } + + if buf.is_empty() { + if offset_start != usize::MAX { + Some(Token { + text: Cow::Borrowed(&self.input[offset_start..=offset_end]), + start: offset_start, + end: offset_end, + stop_char, + }) + } else { + Some(Token { + text: Cow::Borrowed(b"".as_ref()), + start: last_idx, + end: last_idx, + stop_char, + }) + } + } else { + Some(Token { + text: Cow::Owned(buf), + start: offset_start, + end: offset_end, + stop_char, + }) + } + } + + #[inline] + pub(crate) fn token_until_lf(&mut self, last_stop_char: &mut StopChar) -> Option> { + if last_stop_char != &StopChar::Lf { + self.token() + .inspect(|token| *last_stop_char = token.stop_char) + } else { + None + } + } + + #[inline] + pub(crate) fn parse_value_until_lf( + &mut self, + separator: StopChar, + last_stop_char: &mut StopChar, + ) -> Option> + where + T: IanaParse + 'static, + { + if *last_stop_char != separator { + self.token_until_lf(last_stop_char) + .map(|token| T::parse(token.text.as_ref())) + } else { + None + } + } + + pub(crate) fn seek_lf(&mut self) -> bool { + loop { + match self.token() { + Some(Token { + stop_char: StopChar::Lf, + .. + }) => return true, + None => return false, + _ => {} + } + } + } + + pub(crate) fn seek_value_or_eol(&mut self) -> StopChar { + loop { + match self.token() { + Some(Token { + stop_char: StopChar::Colon, + .. + }) => return StopChar::Colon, + Some(Token { + stop_char: StopChar::Lf, + .. + }) + | None => return StopChar::Lf, + _ => {} + } + } + } + + pub(crate) fn seek_param_value_or_eol(&mut self) -> StopChar { + loop { + match self.token() { + Some(Token { + stop_char: stop_char @ (StopChar::Colon | StopChar::Semicolon | StopChar::Equal), + .. + }) => return stop_char, + Some(Token { + stop_char: StopChar::Lf, + .. + }) + | None => return StopChar::Lf, + _ => {} + } + } + } +} + +impl<'x> Token<'x> { + pub fn new(text: Cow<'x, [u8]>) -> Self { + Self { + text, + start: 0, + end: 0, + stop_char: StopChar::Lf, + } + } + + pub fn into_string(self) -> String { + String::from_utf8(self.text.into_owned()) + .unwrap_or_else(|err| String::from_utf8_lossy(&err.into_bytes()).into_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, Eq)] + enum TextOwner { + Borrowed(&'static str), + Owned(String), + } + + #[test] + fn test_tokenizer() { + for (input, expected, disable_stop) in [ + ( + concat!( + "NOTE:This is a long descrip\n", + " tion that exists o\n", + " n a long line.", + ), + vec![ + (TextOwner::Borrowed("NOTE"), StopChar::Colon), + ( + TextOwner::Owned( + "This is a long description that exists on a long line.".into(), + ), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + concat!( + "this is a text value\n", + "this is one value,this is another\n", + "this is a single value\\, with a comma encoded\n" + ), + vec![ + (TextOwner::Borrowed("this is a text value"), StopChar::Lf), + (TextOwner::Borrowed("this is one value"), StopChar::Comma), + (TextOwner::Borrowed("this is another"), StopChar::Lf), + ( + TextOwner::Owned("this is a single value, with a comma encoded".into()), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + "N;ALTID=1;LANGUAGE=en:Yamada;Taro;;;", + vec![ + (TextOwner::Borrowed("N"), StopChar::Semicolon), + (TextOwner::Borrowed("ALTID"), StopChar::Equal), + (TextOwner::Borrowed("1"), StopChar::Semicolon), + (TextOwner::Borrowed("LANGUAGE"), StopChar::Equal), + (TextOwner::Borrowed("en"), StopChar::Colon), + (TextOwner::Borrowed("Yamada"), StopChar::Semicolon), + (TextOwner::Borrowed("Taro"), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Semicolon), + ], + b"".as_slice(), + ), + ( + "N;SORT-AS=\"Mann,James\":de Mann;Henry,James;;", + vec![ + (TextOwner::Borrowed("N"), StopChar::Semicolon), + (TextOwner::Borrowed("SORT-AS"), StopChar::Equal), + (TextOwner::Borrowed("Mann,James"), StopChar::Colon), + (TextOwner::Borrowed("de Mann"), StopChar::Semicolon), + (TextOwner::Borrowed("Henry"), StopChar::Comma), + (TextOwner::Borrowed("James"), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Semicolon), + ], + b"".as_slice(), + ), + ( + " hello\\ nworld\\\\", + vec![(TextOwner::Owned("hello nworld\\".into()), StopChar::Lf)], + b"".as_slice(), + ), + ( + concat!( + "X-ABC-MMSUBJ;VALUE=URI;FMTTYPE=audio/basic:http://www.example.\n", + " org/mysubj.au" + ), + vec![ + (TextOwner::Borrowed("X-ABC-MMSUBJ"), StopChar::Semicolon), + (TextOwner::Borrowed("VALUE"), StopChar::Equal), + (TextOwner::Borrowed("URI"), StopChar::Semicolon), + (TextOwner::Borrowed("FMTTYPE"), StopChar::Equal), + (TextOwner::Borrowed("audio/basic"), StopChar::Colon), + (TextOwner::Borrowed("http"), StopChar::Colon), + ( + TextOwner::Owned("//www.example.org/mysubj.au".into()), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + "RDATE;VALUE=DATE:19970304,19970504,19970704,19970904", + vec![ + (TextOwner::Borrowed("RDATE"), StopChar::Semicolon), + (TextOwner::Borrowed("VALUE"), StopChar::Equal), + (TextOwner::Borrowed("DATE"), StopChar::Colon), + (TextOwner::Borrowed("19970304"), StopChar::Comma), + (TextOwner::Borrowed("19970504"), StopChar::Comma), + (TextOwner::Borrowed("19970704"), StopChar::Comma), + (TextOwner::Borrowed("19970904"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + " BEGIN; ::\n \n \n test", + vec![ + (TextOwner::Borrowed("BEGIN"), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Colon), + (TextOwner::Borrowed(""), StopChar::Colon), + (TextOwner::Borrowed("test"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + concat!( + "DESCRIPTION;Sunday - Partly cloudy with a 20 percent chance of snow show", + "ers. Highs in the lower to mid 40s.\\nMore Information" + ), + vec![ + (TextOwner::Borrowed("DESCRIPTION"), StopChar::Semicolon), + ( + TextOwner::Owned( + concat!( + "Sunday - Partly cloudy with a 20 percent ", + "chance of snow showers. Highs in the lower ", + "to mid 40s.\nMore Information" + ) + .into(), + ), + StopChar::Lf, + ), + ], + b"=:".as_slice(), + ), + ( + concat!( + "ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIH\n", + " F1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4" + ), + vec![ + (TextOwner::Borrowed("ATTACH"), StopChar::Semicolon), + (TextOwner::Borrowed("FMTTYPE"), StopChar::Equal), + (TextOwner::Borrowed("text/plain"), StopChar::Semicolon), + (TextOwner::Borrowed("ENCODING"), StopChar::Equal), + (TextOwner::Borrowed("BASE64"), StopChar::Semicolon), + (TextOwner::Borrowed("VALUE"), StopChar::Equal), + (TextOwner::Borrowed("BINARY"), StopChar::Colon), + ( + TextOwner::Owned( + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4".into(), + ), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + concat!( + "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\":The Fall'98 Wild\n", + " Wizards Conference - - Las Vegas\\, NV\\, USA" + ), + vec![ + (TextOwner::Borrowed("DESCRIPTION"), StopChar::Semicolon), + (TextOwner::Borrowed("ALTREP"), StopChar::Equal), + ( + TextOwner::Borrowed("cid:part1.0001@example.org"), + StopChar::Colon, + ), + ( + TextOwner::Owned( + "The Fall'98 WildWizards Conference - - Las Vegas, NV, USA".to_string(), + ), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + concat!( + "ATTENDEE;DELEGATED-FROM=\"mailto:jsmith@example.com\":mailto:\n", + " jdoe@example.com" + ), + vec![ + (TextOwner::Borrowed("ATTENDEE"), StopChar::Semicolon), + (TextOwner::Borrowed("DELEGATED-FROM"), StopChar::Equal), + ( + TextOwner::Borrowed("mailto:jsmith@example.com"), + StopChar::Colon, + ), + (TextOwner::Borrowed("mailto"), StopChar::Colon), + (TextOwner::Borrowed("jdoe@example.com"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + concat!( + "ATTENDEE;DELEGATED-TO=\"mailto:jdoe@example.com\",\"mailto:jqpublic\n", + " @example.com\":mailto:jsmith@example.com" + ), + vec![ + (TextOwner::Borrowed("ATTENDEE"), StopChar::Semicolon), + (TextOwner::Borrowed("DELEGATED-TO"), StopChar::Equal), + ( + TextOwner::Borrowed("mailto:jdoe@example.com"), + StopChar::Comma, + ), + ( + TextOwner::Owned("mailto:jqpublic@example.com".into()), + StopChar::Colon, + ), + (TextOwner::Borrowed("mailto"), StopChar::Colon), + (TextOwner::Borrowed("jsmith@example.com"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + "CATEGORIES:cat1 , cat2, cat3", + vec![ + (TextOwner::Borrowed("CATEGORIES"), StopChar::Colon), + (TextOwner::Borrowed("cat1"), StopChar::Comma), + (TextOwner::Borrowed("cat2"), StopChar::Comma), + (TextOwner::Borrowed("cat3"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + concat!("SUMMARY:Meeting\n", "\n\n", "BEGIN:VALARM"), + vec![ + (TextOwner::Borrowed("SUMMARY"), StopChar::Colon), + (TextOwner::Borrowed("Meeting"), StopChar::Lf), + (TextOwner::Borrowed(""), StopChar::Lf), + (TextOwner::Borrowed(""), StopChar::Lf), + (TextOwner::Borrowed("BEGIN"), StopChar::Colon), + (TextOwner::Borrowed("VALARM"), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + concat!( + "FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=B3=D0=BE=D1=80=20", + "=D0=97=D0=B0=D0=BE=D1=80=D1=81=D0=81=D0=\n", + "=BA=\n", + "=D1=96=\n", + " xyz =\n", + "=D1=96\n" + ), + vec![ + (TextOwner::Borrowed("FN"), StopChar::Semicolon), + (TextOwner::Borrowed("CHARSET"), StopChar::Equal), + (TextOwner::Borrowed("UTF-8"), StopChar::Semicolon), + (TextOwner::Borrowed("ENCODING"), StopChar::Equal), + (TextOwner::Borrowed("QUOTED-PRINTABLE"), StopChar::Colon), + ( + TextOwner::Borrowed(concat!( + "=D0=B3=D0=BE=D1=80=20=D0=97=D0=B0=D0=BE=D1=80", + "=D1=81=D0=81=D0=\n=BA=\n=D1=96=\n xyz =\n=D1=96" + )), + StopChar::Lf, + ), + ], + b"".as_slice(), + ), + ( + concat!( + "ADR;LABEL=\"Mr. John Q. Public, Esq.\\nMail Drop: TNE QB\\n123\n", + " Main Street\\nAny Town, CA 91921-1234\\nU.S.A.\":\n", + " ;;123 Main Street;Any Town;CA;91921-1234;U.S.A." + ), + vec![ + (TextOwner::Borrowed("ADR"), StopChar::Semicolon), + (TextOwner::Borrowed("LABEL"), StopChar::Equal), + ( + TextOwner::Owned( + concat!( + "Mr. John Q. Public, Esq.\nMail Drop: TNE QB\n", + "123Main Street\nAny Town, CA 91921-1234\nU.S.A." + ) + .into(), + ), + StopChar::Colon, + ), + (TextOwner::Borrowed(""), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Semicolon), + (TextOwner::Borrowed("123 Main Street"), StopChar::Semicolon), + (TextOwner::Borrowed("Any Town"), StopChar::Semicolon), + (TextOwner::Borrowed("CA"), StopChar::Semicolon), + (TextOwner::Borrowed("91921-1234"), StopChar::Semicolon), + (TextOwner::Borrowed("U.S.A."), StopChar::Lf), + ], + b"".as_slice(), + ), + ( + "\\", + vec![(TextOwner::Owned("\\".into()), StopChar::Lf)], + b"".as_slice(), + ), + ( + "\\n", + vec![(TextOwner::Owned("\n".into()), StopChar::Lf)], + b"".as_slice(), + ), + ( + "\\nhello", + vec![(TextOwner::Owned("\nhello".into()), StopChar::Lf)], + b"".as_slice(), + ), + ( + ";;\nEND:VCARD\n", + vec![ + (TextOwner::Borrowed(""), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Semicolon), + (TextOwner::Borrowed(""), StopChar::Lf), + (TextOwner::Borrowed("END"), StopChar::Colon), + (TextOwner::Borrowed("VCARD"), StopChar::Lf), + ], + b"".as_slice(), + ), + ("", vec![], b"".as_slice()), + ] { + let mut parser = Parser::new(input); + let mut tokens = vec![]; + parser.skip_ws = true; + for ch in disable_stop { + match ch { + b'=' => { + parser.stop_equal = false; + } + b':' => { + parser.stop_colon = false; + } + _ => {} + } + } + + while let Some(token) = parser.token() { + if token.text.eq_ignore_ascii_case(b"quoted-printable") { + parser.stop_colon = false; + parser.stop_comma = false; + parser.stop_equal = false; + parser.unquote = false; + parser.unfold_qp = true; + parser.stop_semicolon = true; + parser.skip_ws = false; + } + let text = match token.text { + Cow::Borrowed(text) => TextOwner::Borrowed(std::str::from_utf8(text).unwrap()), + Cow::Owned(text) => TextOwner::Owned(String::from_utf8(text).unwrap()), + }; + tokens.push((text, token.stop_char)); + } + assert_eq!(tokens, expected, "failed for input: {:?}", input); + } + } +} diff --git a/vendor/calcard/src/common/types.rs b/vendor/calcard/src/common/types.rs new file mode 100644 index 00000000..b1ced6db --- /dev/null +++ b/vendor/calcard/src/common/types.rs @@ -0,0 +1,451 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; + +impl IanaString for CalendarScale { + fn as_str(&self) -> &'static str { + match self { + CalendarScale::Gregorian => "GREGORIAN", + CalendarScale::Chinese => "CHINESE", + CalendarScale::IslamicCivil => "ISLAMIC-CIVIL", + CalendarScale::Hebrew => "HEBREW", + CalendarScale::Ethiopic => "ETHIOPIC", + } + } +} + +#[cfg(feature = "jmap")] +impl CalendarScale { + pub fn as_js_str(&self) -> &'static str { + match self { + CalendarScale::Gregorian => "gregorian", + CalendarScale::Chinese => "chinese", + CalendarScale::IslamicCivil => "islamic-civil", + CalendarScale::Hebrew => "hebrew", + CalendarScale::Ethiopic => "ethiopic", + } + } +} + +#[cfg(feature = "rkyv")] +impl IanaString for ArchivedCalendarScale { + fn as_str(&self) -> &'static str { + match self { + ArchivedCalendarScale::Gregorian => "GREGORIAN", + ArchivedCalendarScale::Chinese => "CHINESE", + ArchivedCalendarScale::IslamicCivil => "ISLAMIC-CIVIL", + ArchivedCalendarScale::Hebrew => "HEBREW", + ArchivedCalendarScale::Ethiopic => "ETHIOPIC", + } + } +} + +impl IanaParse for CalendarScale { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "gregorian" => CalendarScale::Gregorian, + "chinese" => CalendarScale::Chinese, + "islamic-civil" => CalendarScale::IslamicCivil, + "hebrew" => CalendarScale::Hebrew, + "ethiopic" => CalendarScale::Ethiopic, + ) + } +} + +impl Encoding { + pub fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + b"QUOTED-PRINTABLE" => Encoding::QuotedPrintable, + b"BASE64" => Encoding::Base64, + b"Q" => Encoding::QuotedPrintable, + b"B" => Encoding::Base64, + ) + } +} + +impl PartialDateTime { + pub fn now() -> Self { + Self::from_utc_timestamp(chrono::Utc::now().timestamp()) + } + + pub fn from_utc_timestamp(value: i64) -> Self { + let dt = DateTime::from_timestamp(value); + + PartialDateTime { + year: dt.year.into(), + month: dt.month.into(), + day: dt.day.into(), + hour: dt.hour.into(), + minute: dt.minute.into(), + second: dt.second.into(), + tz_hour: dt.tz_hour.into(), + tz_minute: dt.tz_minute.into(), + tz_minus: false, + } + } + + pub fn from_date_timestamp(value: i64) -> Self { + let dt = DateTime::from_timestamp(value); + + PartialDateTime { + year: dt.year.into(), + month: dt.month.into(), + day: dt.day.into(), + ..Default::default() + } + } + + pub fn from_naive_timestamp(value: i64) -> Self { + let dt = DateTime::from_timestamp(value); + + PartialDateTime { + year: dt.year.into(), + month: dt.month.into(), + day: dt.day.into(), + hour: dt.hour.into(), + minute: dt.minute.into(), + second: dt.second.into(), + ..Default::default() + } + } + + pub fn to_date_time(&self) -> Option { + let mut dt = DateTimeResult { + date_time: NaiveDate::from_ymd_opt( + self.year? as i32, + self.month? as u32, + self.day? as u32, + )? + .and_hms_opt( + self.hour.unwrap_or(0) as u32, + self.minute.unwrap_or(0) as u32, + self.second.unwrap_or(0) as u32, + )?, + offset: None, + }; + if let Some(tz_hour) = self.tz_hour { + let secs = (tz_hour as i32 * 3600) + (self.tz_minute.unwrap_or(0) as i32 * 60); + dt.offset = if self.tz_minus { + FixedOffset::west_opt(secs)? + } else { + FixedOffset::east_opt(secs)? + } + .into(); + } + Some(dt) + } +} + +#[cfg(feature = "rkyv")] +impl ArchivedPartialDateTime { + pub fn to_date_time(&self) -> Option { + let mut dt = DateTimeResult { + date_time: NaiveDate::from_ymd_opt( + self.year.as_ref()?.to_native() as i32, + *self.month.as_ref()? as u32, + *self.day.as_ref()? as u32, + )? + .and_hms_opt( + self.hour.unwrap_or(0) as u32, + self.minute.unwrap_or(0) as u32, + self.second.unwrap_or(0) as u32, + )?, + offset: None, + }; + if let Some(tz_hour) = self.tz_hour.as_ref() { + let secs = (*tz_hour as i32 * 3600) + (self.tz_minute.unwrap_or(0) as i32 * 60); + dt.offset = if self.tz_minus { + FixedOffset::west_opt(secs)? + } else { + FixedOffset::east_opt(secs)? + } + .into(); + } + Some(dt) + } +} + +impl IanaParse for LinkRelation { + fn parse(s: &[u8]) -> Option { + hashify::map_ignore_case!(s, LinkRelation, + "about" => LinkRelation::About, + "acl" => LinkRelation::Acl, + "alternate" => LinkRelation::Alternate, + "amphtml" => LinkRelation::Amphtml, + "api-catalog" => LinkRelation::ApiCatalog, + "appendix" => LinkRelation::Appendix, + "apple-touch-icon" => LinkRelation::AppleTouchIcon, + "apple-touch-startup-image" => LinkRelation::AppleTouchStartupImage, + "archives" => LinkRelation::Archives, + "author" => LinkRelation::Author, + "blocked-by" => LinkRelation::BlockedBy, + "bookmark" => LinkRelation::Bookmark, + "c2pa-manifest" => LinkRelation::C2paManifest, + "canonical" => LinkRelation::Canonical, + "chapter" => LinkRelation::Chapter, + "cite-as" => LinkRelation::CiteAs, + "collection" => LinkRelation::Collection, + "compression-dictionary" => LinkRelation::CompressionDictionary, + "contents" => LinkRelation::Contents, + "convertedfrom" => LinkRelation::Convertedfrom, + "copyright" => LinkRelation::Copyright, + "create-form" => LinkRelation::CreateForm, + "current" => LinkRelation::Current, + "deprecation" => LinkRelation::Deprecation, + "describedby" => LinkRelation::Describedby, + "describes" => LinkRelation::Describes, + "disclosure" => LinkRelation::Disclosure, + "dns-prefetch" => LinkRelation::DnsPrefetch, + "duplicate" => LinkRelation::Duplicate, + "edit" => LinkRelation::Edit, + "edit-form" => LinkRelation::EditForm, + "edit-media" => LinkRelation::EditMedia, + "enclosure" => LinkRelation::Enclosure, + "external" => LinkRelation::External, + "first" => LinkRelation::First, + "geofeed" => LinkRelation::Geofeed, + "glossary" => LinkRelation::Glossary, + "help" => LinkRelation::Help, + "hosts" => LinkRelation::Hosts, + "hub" => LinkRelation::Hub, + "ice-server" => LinkRelation::IceServer, + "icon" => LinkRelation::Icon, + "index" => LinkRelation::Index, + "intervalafter" => LinkRelation::Intervalafter, + "intervalbefore" => LinkRelation::Intervalbefore, + "intervalcontains" => LinkRelation::Intervalcontains, + "intervaldisjoint" => LinkRelation::Intervaldisjoint, + "intervalduring" => LinkRelation::Intervalduring, + "intervalequals" => LinkRelation::Intervalequals, + "intervalfinishedby" => LinkRelation::Intervalfinishedby, + "intervalfinishes" => LinkRelation::Intervalfinishes, + "intervalin" => LinkRelation::Intervalin, + "intervalmeets" => LinkRelation::Intervalmeets, + "intervalmetby" => LinkRelation::Intervalmetby, + "intervaloverlappedby" => LinkRelation::Intervaloverlappedby, + "intervaloverlaps" => LinkRelation::Intervaloverlaps, + "intervalstartedby" => LinkRelation::Intervalstartedby, + "intervalstarts" => LinkRelation::Intervalstarts, + "item" => LinkRelation::Item, + "last" => LinkRelation::Last, + "latest-version" => LinkRelation::LatestVersion, + "license" => LinkRelation::License, + "linkset" => LinkRelation::Linkset, + "lrdd" => LinkRelation::Lrdd, + "manifest" => LinkRelation::Manifest, + "mask-icon" => LinkRelation::MaskIcon, + "me" => LinkRelation::Me, + "media-feed" => LinkRelation::MediaFeed, + "memento" => LinkRelation::Memento, + "micropub" => LinkRelation::Micropub, + "modulepreload" => LinkRelation::Modulepreload, + "monitor" => LinkRelation::Monitor, + "monitor-group" => LinkRelation::MonitorGroup, + "next" => LinkRelation::Next, + "next-archive" => LinkRelation::NextArchive, + "nofollow" => LinkRelation::Nofollow, + "noopener" => LinkRelation::Noopener, + "noreferrer" => LinkRelation::Noreferrer, + "opener" => LinkRelation::Opener, + "openid2.local_id" => LinkRelation::Openid2LocalId, + "openid2.provider" => LinkRelation::Openid2Provider, + "original" => LinkRelation::Original, + "p3pv1" => LinkRelation::P3pv1, + "payment" => LinkRelation::Payment, + "pingback" => LinkRelation::Pingback, + "preconnect" => LinkRelation::Preconnect, + "predecessor-version" => LinkRelation::PredecessorVersion, + "prefetch" => LinkRelation::Prefetch, + "preload" => LinkRelation::Preload, + "prerender" => LinkRelation::Prerender, + "prev" => LinkRelation::Prev, + "preview" => LinkRelation::Preview, + "previous" => LinkRelation::Previous, + "prev-archive" => LinkRelation::PrevArchive, + "privacy-policy" => LinkRelation::PrivacyPolicy, + "profile" => LinkRelation::Profile, + "publication" => LinkRelation::Publication, + "rdap-active" => LinkRelation::RdapActive, + "rdap-bottom" => LinkRelation::RdapBottom, + "rdap-down" => LinkRelation::RdapDown, + "rdap-top" => LinkRelation::RdapTop, + "rdap-up" => LinkRelation::RdapUp, + "related" => LinkRelation::Related, + "restconf" => LinkRelation::Restconf, + "replies" => LinkRelation::Replies, + "ruleinput" => LinkRelation::Ruleinput, + "search" => LinkRelation::Search, + "section" => LinkRelation::Section, + "self" => LinkRelation::Self_, + "service" => LinkRelation::Service, + "service-desc" => LinkRelation::ServiceDesc, + "service-doc" => LinkRelation::ServiceDoc, + "service-meta" => LinkRelation::ServiceMeta, + "sip-trunking-capability" => LinkRelation::SipTrunkingCapability, + "sponsored" => LinkRelation::Sponsored, + "start" => LinkRelation::Start, + "status" => LinkRelation::Status, + "stylesheet" => LinkRelation::Stylesheet, + "subsection" => LinkRelation::Subsection, + "successor-version" => LinkRelation::SuccessorVersion, + "sunset" => LinkRelation::Sunset, + "tag" => LinkRelation::Tag, + "terms-of-service" => LinkRelation::TermsOfService, + "timegate" => LinkRelation::Timegate, + "timemap" => LinkRelation::Timemap, + "type" => LinkRelation::Type, + "ugc" => LinkRelation::Ugc, + "up" => LinkRelation::Up, + "version-history" => LinkRelation::VersionHistory, + "via" => LinkRelation::Via, + "webmention" => LinkRelation::Webmention, + "working-copy" => LinkRelation::WorkingCopy, + "working-copy-of" => LinkRelation::WorkingCopyOf, + ) + .copied() + } +} + +impl IanaString for LinkRelation { + fn as_str(&self) -> &'static str { + match self { + LinkRelation::About => "about", + LinkRelation::Acl => "acl", + LinkRelation::Alternate => "alternate", + LinkRelation::Amphtml => "amphtml", + LinkRelation::ApiCatalog => "api-catalog", + LinkRelation::Appendix => "appendix", + LinkRelation::AppleTouchIcon => "apple-touch-icon", + LinkRelation::AppleTouchStartupImage => "apple-touch-startup-image", + LinkRelation::Archives => "archives", + LinkRelation::Author => "author", + LinkRelation::BlockedBy => "blocked-by", + LinkRelation::Bookmark => "bookmark", + LinkRelation::C2paManifest => "c2pa-manifest", + LinkRelation::Canonical => "canonical", + LinkRelation::Chapter => "chapter", + LinkRelation::CiteAs => "cite-as", + LinkRelation::Collection => "collection", + LinkRelation::CompressionDictionary => "compression-dictionary", + LinkRelation::Contents => "contents", + LinkRelation::Convertedfrom => "convertedfrom", + LinkRelation::Copyright => "copyright", + LinkRelation::CreateForm => "create-form", + LinkRelation::Current => "current", + LinkRelation::Deprecation => "deprecation", + LinkRelation::Describedby => "describedby", + LinkRelation::Describes => "describes", + LinkRelation::Disclosure => "disclosure", + LinkRelation::DnsPrefetch => "dns-prefetch", + LinkRelation::Duplicate => "duplicate", + LinkRelation::Edit => "edit", + LinkRelation::EditForm => "edit-form", + LinkRelation::EditMedia => "edit-media", + LinkRelation::Enclosure => "enclosure", + LinkRelation::External => "external", + LinkRelation::First => "first", + LinkRelation::Geofeed => "geofeed", + LinkRelation::Glossary => "glossary", + LinkRelation::Help => "help", + LinkRelation::Hosts => "hosts", + LinkRelation::Hub => "hub", + LinkRelation::IceServer => "ice-server", + LinkRelation::Icon => "icon", + LinkRelation::Index => "index", + LinkRelation::Intervalafter => "intervalafter", + LinkRelation::Intervalbefore => "intervalbefore", + LinkRelation::Intervalcontains => "intervalcontains", + LinkRelation::Intervaldisjoint => "intervaldisjoint", + LinkRelation::Intervalduring => "intervalduring", + LinkRelation::Intervalequals => "intervalequals", + LinkRelation::Intervalfinishedby => "intervalfinishedby", + LinkRelation::Intervalfinishes => "intervalfinishes", + LinkRelation::Intervalin => "intervalin", + LinkRelation::Intervalmeets => "intervalmeets", + LinkRelation::Intervalmetby => "intervalmetby", + LinkRelation::Intervaloverlappedby => "intervaloverlappedby", + LinkRelation::Intervaloverlaps => "intervaloverlaps", + LinkRelation::Intervalstartedby => "intervalstartedby", + LinkRelation::Intervalstarts => "intervalstarts", + LinkRelation::Item => "item", + LinkRelation::Last => "last", + LinkRelation::LatestVersion => "latest-version", + LinkRelation::License => "license", + LinkRelation::Linkset => "linkset", + LinkRelation::Lrdd => "lrdd", + LinkRelation::Manifest => "manifest", + LinkRelation::MaskIcon => "mask-icon", + LinkRelation::Me => "me", + LinkRelation::MediaFeed => "media-feed", + LinkRelation::Memento => "memento", + LinkRelation::Micropub => "micropub", + LinkRelation::Modulepreload => "modulepreload", + LinkRelation::Monitor => "monitor", + LinkRelation::MonitorGroup => "monitor-group", + LinkRelation::Next => "next", + LinkRelation::NextArchive => "next-archive", + LinkRelation::Nofollow => "nofollow", + LinkRelation::Noopener => "noopener", + LinkRelation::Noreferrer => "noreferrer", + LinkRelation::Opener => "opener", + LinkRelation::Openid2LocalId => "openid2.local_id", + LinkRelation::Openid2Provider => "openid2.provider", + LinkRelation::Original => "original", + LinkRelation::P3pv1 => "p3pv1", + LinkRelation::Payment => "payment", + LinkRelation::Pingback => "pingback", + LinkRelation::Preconnect => "preconnect", + LinkRelation::PredecessorVersion => "predecessor-version", + LinkRelation::Prefetch => "prefetch", + LinkRelation::Preload => "preload", + LinkRelation::Prerender => "prerender", + LinkRelation::Prev => "prev", + LinkRelation::Preview => "preview", + LinkRelation::Previous => "previous", + LinkRelation::PrevArchive => "prev-archive", + LinkRelation::PrivacyPolicy => "privacy-policy", + LinkRelation::Profile => "profile", + LinkRelation::Publication => "publication", + LinkRelation::RdapActive => "rdap-active", + LinkRelation::RdapBottom => "rdap-bottom", + LinkRelation::RdapDown => "rdap-down", + LinkRelation::RdapTop => "rdap-top", + LinkRelation::RdapUp => "rdap-up", + LinkRelation::Related => "related", + LinkRelation::Restconf => "restconf", + LinkRelation::Replies => "replies", + LinkRelation::Ruleinput => "ruleinput", + LinkRelation::Search => "search", + LinkRelation::Section => "section", + LinkRelation::Self_ => "self", + LinkRelation::Service => "service", + LinkRelation::ServiceDesc => "service-desc", + LinkRelation::ServiceDoc => "service-doc", + LinkRelation::ServiceMeta => "service-meta", + LinkRelation::SipTrunkingCapability => "sip-trunking-capability", + LinkRelation::Sponsored => "sponsored", + LinkRelation::Start => "start", + LinkRelation::Status => "status", + LinkRelation::Stylesheet => "stylesheet", + LinkRelation::Subsection => "subsection", + LinkRelation::SuccessorVersion => "successor-version", + LinkRelation::Sunset => "sunset", + LinkRelation::Tag => "tag", + LinkRelation::TermsOfService => "terms-of-service", + LinkRelation::Timegate => "timegate", + LinkRelation::Timemap => "timemap", + LinkRelation::Type => "type", + LinkRelation::Ugc => "ugc", + LinkRelation::Up => "up", + LinkRelation::VersionHistory => "version-history", + LinkRelation::Via => "via", + LinkRelation::Webmention => "webmention", + LinkRelation::WorkingCopy => "working-copy", + LinkRelation::WorkingCopyOf => "working-copy-of", + } + } +} diff --git a/vendor/calcard/src/common/writer.rs b/vendor/calcard/src/common/writer.rs new file mode 100644 index 00000000..be4f9dd8 --- /dev/null +++ b/vendor/calcard/src/common/writer.rs @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::parser::Timestamp; +use crate::vcard::Jscomp; +use mail_builder::encoders::base64::*; +use mail_parser::DateTime; +use std::fmt::{Display, Write}; + +pub(crate) const MAX_LINE_LEN: usize = 75; + +pub(crate) trait LineWriter: Write { + fn write_atomic(&mut self, text: &str) -> std::fmt::Result; +} + +pub(crate) struct FoldingWriter<'x, W: Write> { + out: &'x mut W, + line_len: usize, +} + +impl<'x, W: Write> FoldingWriter<'x, W> { + pub(crate) fn new(out: &'x mut W) -> Self { + Self { out, line_len: 0 } + } + + pub(crate) fn end_line(&mut self) -> std::fmt::Result { + self.line_len = 0; + self.out.write_str("\r\n") + } +} + +impl LineWriter for FoldingWriter<'_, W> { + fn write_atomic(&mut self, text: &str) -> std::fmt::Result { + let len = text.len(); + if len > MAX_LINE_LEN - 1 { + return self.write_str(text); + } + if self.line_len + len > MAX_LINE_LEN { + self.out.write_str("\r\n ")?; + self.line_len = 1; + } + self.out.write_str(text)?; + self.line_len += len; + Ok(()) + } +} + +impl LineWriter for String { + fn write_atomic(&mut self, text: &str) -> std::fmt::Result { + self.push_str(text); + Ok(()) + } +} + +impl Write for FoldingWriter<'_, W> { + fn write_str(&mut self, text: &str) -> std::fmt::Result { + let mut rest = text; + + loop { + let available = MAX_LINE_LEN.saturating_sub(self.line_len); + + if rest.len() <= available { + self.line_len += rest.len(); + return self.out.write_str(rest); + } + + let mut split = available; + while split > 0 && !rest.is_char_boundary(split) { + split -= 1; + } + + if split == 0 { + self.out.write_str("\r\n ")?; + self.line_len = 1; + continue; + } + + self.out.write_str(&rest[..split])?; + self.line_len += split; + rest = &rest[split..]; + } + } + + fn write_char(&mut self, ch: char) -> std::fmt::Result { + let ch_len = ch.len_utf8(); + if self.line_len + ch_len > MAX_LINE_LEN { + self.out.write_str("\r\n ")?; + self.line_len = 1; + } + self.line_len += ch_len; + self.out.write_char(ch) + } +} + +pub(crate) fn write_text( + out: &mut impl LineWriter, + value: &str, + escape_semicolon: bool, + escape_comma: bool, +) -> std::fmt::Result { + for ch in value.chars() { + match ch { + '\r' => out.write_atomic("\\r")?, + '\n' => out.write_atomic("\\n")?, + '\\' => out.write_atomic("\\\\")?, + ';' if escape_semicolon => out.write_atomic("\\;")?, + ',' if escape_comma => out.write_atomic("\\,")?, + _ => out.write_char(ch)?, + } + } + + Ok(()) +} + +pub(crate) fn write_bytes(out: &mut impl Write, value: &[u8]) -> std::fmt::Result { + const CHARPAD: u8 = b'='; + + let mut i = 0; + let mut t1; + let mut t2; + let mut t3; + + if value.len() > 2 { + while i < value.len() - 2 { + t1 = value[i]; + t2 = value[i + 1]; + t3 = value[i + 2]; + + for ch in [ + E0[t1 as usize], + E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize], + E1[(((t2 & 0x0F) << 2) | ((t3 >> 6) & 0x03)) as usize], + E2[t3 as usize], + ] { + out.write_char(char::from(ch))?; + } + + i += 3; + } + } + + let remaining = value.len() - i; + if remaining > 0 { + t1 = value[i]; + let chs = if remaining == 1 { + [ + E0[t1 as usize], + E1[((t1 & 0x03) << 4) as usize], + CHARPAD, + CHARPAD, + ] + } else { + t2 = value[i + 1]; + [ + E0[t1 as usize], + E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize], + E2[((t2 & 0x0F) << 2) as usize], + CHARPAD, + ] + }; + + for ch in chs.iter() { + out.write_char(char::from(*ch))?; + } + } + + Ok(()) +} + +pub(crate) trait NeedsQuotes { + fn needs_quotes(&self) -> bool; +} + +impl> NeedsQuotes for T { + fn needs_quotes(&self) -> bool { + self.as_ref() + .iter() + .any(|&ch| matches!(ch, b',' | b':' | b'=' | b' ' | b';' | b'"')) + } +} + +pub(crate) fn write_param_value(out: &mut impl LineWriter, value: &str) -> std::fmt::Result { + let needs_quotes = value.needs_quotes(); + + if needs_quotes { + out.write_atomic("\"")?; + } + + for ch in value.chars() { + match ch as u32 { + 0x0A => out.write_atomic("\\n")?, + 0x0D => out.write_atomic("\\r")?, + 0x5C => out.write_atomic("\\\\")?, + 0x22 => out.write_atomic("\\\"")?, + 0x20 | 0x09 | 0x21 | 0x23..=0x7E | 0x80.. => out.write_char(ch)?, + _ => {} + } + } + + if needs_quotes { + out.write_atomic("\"")?; + } + + Ok(()) +} + +pub(crate) fn write_jscomps(out: &mut impl LineWriter, values: &[Jscomp]) -> std::fmt::Result { + for (pos, item) in values.iter().enumerate() { + if pos > 0 { + out.write_atomic(";")?; + } + match item { + Jscomp::Entry { position, value } => { + write!(out, "{position}")?; + if *value > 0 { + write!(out, ",{value}")?; + } + } + Jscomp::Separator(s) => { + if !s.is_empty() { + out.write_atomic("s,")?; + + for ch in s.chars() { + match ch { + '\\' => out.write_atomic("\\\\")?, + ',' => out.write_atomic("\\,")?, + ':' => out.write_atomic("\\:")?, + '=' => out.write_atomic("\\=")?, + ';' => out.write_atomic("\\;")?, + '"' => out.write_atomic("\\\"")?, + '\r' | '\n' => {} + _ => out.write_char(ch)?, + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +pub(crate) fn assert_fold_width(text: &str, context: &str) { + let mut lines = text.split("\r\n").peekable(); + + while let Some(line) = lines.next() { + assert!( + line.len() <= MAX_LINE_LEN, + "physical line of {} octets exceeds the {MAX_LINE_LEN} octet fold width in {context}: {line:?}", + line.len() + ); + assert!( + !line.is_empty() || lines.peek().is_none(), + "empty physical line in {context}: {text:?}" + ); + assert!( + line != " ", + "continuation line holding nothing but the fold character in {context}: {text:?}" + ); + } +} + +impl Display for Timestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let dt = DateTime::from_timestamp(self.0); + write!( + f, + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second + ) + } +} diff --git a/vendor/calcard/src/datecalc/checks.rs b/vendor/calcard/src/datecalc/checks.rs new file mode 100644 index 00000000..d208964e --- /dev/null +++ b/vendor/calcard/src/datecalc/checks.rs @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{error::ValidationError, validate::YEAR_RANGE}; + +pub(crate) fn check_year_range(year: i32) -> Result<(), ValidationError> { + if YEAR_RANGE.contains(&year) { + Ok(()) + } else { + Err(ValidationError::InvalidFieldValueRange { + field: "YEAR".into(), + value: year.to_string(), + start_idx: YEAR_RANGE.start().to_string(), + end_idx: YEAR_RANGE.end().to_string(), + }) + } +} diff --git a/vendor/calcard/src/datecalc/counter_date.rs b/vendor/calcard/src/datecalc/counter_date.rs new file mode 100644 index 00000000..44289635 --- /dev/null +++ b/vendor/calcard/src/datecalc/counter_date.rs @@ -0,0 +1,783 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +use super::{ + checks, + error::{RRuleError, checked_add_u32, checked_mul_u32}, + masks::MASKS, + rrule::RRule, + utils::is_leap_year, +}; +use crate::{common::timezone::Tz, icalendar::ICalendarFrequency}; +use chrono::{Datelike, TimeZone, Timelike, Utc, Weekday}; +use std::collections::HashSet; + +const MINUTES_IN_A_DAY: u32 = 60 * 24; +const SECONDS_IN_A_DAY: u32 = 60 * 60 * 24; + +/// A simple date time type used during iteration. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DateTimeIter { + pub year: i32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + pub second: u32, +} + +impl DateTimeIter { + /// Increments the datetime based on the [`RRule`] values. + /// + /// If `increment_day` is set to `true`, then the method will also make sure that + /// the day is incremented. This is useful is the case where `rrule.freq` + /// is higher than daily (e.g. hourly) where this function might return a date with the + /// same day, but the iterator already knows that the current day can't + /// be part of the result. + #[cfg(test)] + pub fn increment(&mut self, rrule: &RRule, increment_day: bool) -> Result<(), RRuleError> { + self.increment_counted(rrule, increment_day, &mut 0) + } + + /// inbuxa: `increment`, adding to `steps` each pass of the search loops + /// that skip the times `BYHOUR`, `BYMINUTE` and `BYSECOND` exclude, so the + /// caller can charge them against its expansion budget. + pub fn increment_counted( + &mut self, + rrule: &RRule, + increment_day: bool, + steps: &mut u32, + ) -> Result<(), RRuleError> { + let RRule { + interval, + week_start, + by_hour, + by_minute, + by_second, + .. + } = rrule; + match rrule.freq { + ICalendarFrequency::Yearly => self.increment_yearly(*interval), + ICalendarFrequency::Monthly => self.increment_monthly(*interval), + ICalendarFrequency::Weekly => self.increment_weekly(*interval, *week_start), + ICalendarFrequency::Daily => self.increment_daily(*interval), + ICalendarFrequency::Hourly => { + self.increment_hourly(*interval, by_hour, increment_day, steps) + } + ICalendarFrequency::Minutely => { + self.increment_minutely(*interval, by_hour, by_minute, increment_day, steps) + } + ICalendarFrequency::Secondly => self.increment_secondly( + *interval, + by_hour, + by_minute, + by_second, + increment_day, + steps, + ), + } + } + + fn increment_yearly(&mut self, interval: u16) -> Result<(), RRuleError> { + self.year += i32::from(interval); + checks::check_year_range(self.year)?; + self.fix_day() + } + + fn increment_monthly(&mut self, interval: u16) -> Result<(), RRuleError> { + self.month += u32::from(interval); + if self.month > 12 { + let mut year_div = u16::try_from(self.month).map_err(|_| { + RRuleError::new_iter_err( + "Encountered a too high new month. Please decrease the rrule interval.", + ) + })? / 12; + self.month %= 12; + if self.month == 0 { + self.month = 12; + year_div -= 1; + } + self.year += i32::from(year_div); + checks::check_year_range(self.year)?; + } + Ok(()) + } + + fn get_weekday(&self) -> u32 { + let month_range_mask = if is_leap_year(self.year) { + &MASKS.month_366_range + } else { + &MASKS.month_365_range + }; + let year_day = u32::from(month_range_mask[self.month as usize - 1]) + self.day - 1; + let year_day_mod = year_day % 7; + let year_start_weekday = Utc + .with_ymd_and_hms(self.year, 1, 1, 0, 0, 0) + // It should never fail, since there is always a 1st of January, is there? + .unwrap() + .weekday() + .num_days_from_monday(); + + (year_day_mod + year_start_weekday) % 7 + } + + fn increment_weekly(&mut self, interval: u16, week_start: Weekday) -> Result<(), RRuleError> { + let weekday = self.get_weekday(); + let option_week_start = week_start.num_days_from_monday(); + let interval = u32::from(interval); + // Calculate the amount of day to move forward. + let day_delta = if option_week_start > weekday { + // `weekday` and `option_week_start` can only be in range `0..=6` + // `option_week_start` > `weekday` so: + // `(weekday + 1 + 6 - option_week_start)` > 0 so can never be negative. + interval * 7 - (weekday + 7 - option_week_start) + } else { + // This can also never be negative + interval * 7 - (weekday - option_week_start) + }; + self.day += day_delta; + self.fix_day() + } + + fn days_in_month(&self) -> u32 { + if self.month == 0 { + 31 + } else { + let month_range_mask = if is_leap_year(self.year) { + &MASKS.month_366_range + } else { + &MASKS.month_365_range + }; + let days_in_month = + month_range_mask[self.month as usize] - month_range_mask[self.month as usize - 1]; + u32::from(days_in_month) + } + } + + fn fix_day(&mut self) -> Result<(), RRuleError> { + if self.day <= 28 { + return Ok(()); + } + + let mut days_in_month = self.days_in_month(); + if self.day <= days_in_month { + return Ok(()); + } + + while self.day > days_in_month { + self.day -= days_in_month; + self.increment_monthly(1)?; + + days_in_month = self.days_in_month(); + } + Ok(()) + } + + fn increment_daily(&mut self, interval: u16) -> Result<(), RRuleError> { + self.day += u32::from(interval); + self.fix_day() + } + + fn increment_hourly( + &mut self, + interval: u16, + by_hour: &[u8], + increment_day: bool, + steps: &mut u32, + ) -> Result<(), RRuleError> { + if increment_day { + // Jump to one iteration before the next day + let temp_value = (23 - self.hour) / u32::from(interval); + self.hour += checked_mul_u32( + temp_value, + u32::from(interval), + Some("please decrease `INTERVAL`"), + )?; + } + + let mut prev_hours = HashSet::new(); + loop { + *steps = steps.saturating_add(1); + self.hour = checked_add_u32( + self.hour, + u32::from(interval), + Some("please decrease `INTERVAL`"), + )?; + let new_hours = (self.hour % 24) as u8; + if by_hour.is_empty() || by_hour.contains(&new_hours) { + break; + } + if prev_hours.contains(&new_hours) { + return Err(RRuleError::new_iter_err( + "Infinite loop detected. It can be resolved by changing `BYHOUR` or `INTERVAL`", + )); + } + prev_hours.insert(new_hours); + } + + let new_days = u16::try_from(self.hour / 24).map_err(|_| { + RRuleError::new_iter_err("unexpected high hour, please decrease the interval") + })?; + self.hour %= 24; + if new_days > 0 { + self.increment_daily(new_days) + } else { + Ok(()) + } + } + + fn increment_minutely( + &mut self, + interval: u16, + by_hour: &[u8], + by_minute: &[u8], + increment_day: bool, + steps: &mut u32, + ) -> Result<(), RRuleError> { + if increment_day { + // Jump to one iteration before the next day + let minutes_total = self.hour * 60 + self.minute; + let temp_value = (MINUTES_IN_A_DAY - 1 - minutes_total) / u32::from(interval); + self.minute += checked_mul_u32( + temp_value, + u32::from(interval), + Some("please decrease `INTERVAL`"), + )?; + } + + let mut prev_values = HashSet::new(); + loop { + *steps = steps.saturating_add(1); + self.minute += u32::from(interval); + let hours_div = u16::try_from(self.minute / 60).map_err(|_| { + RRuleError::new_iter_err("unexpected high minute, please decrease interval") + })?; + if hours_div > 0 { + self.minute %= 60; + self.increment_hourly(hours_div, by_hour, increment_day, steps)?; + } + + let hours = (self.hour % 24) as u8; + let minutes = (self.minute % 60) as u8; + + if (by_hour.is_empty() || by_hour.contains(&hours)) + && (by_minute.is_empty() || by_minute.contains(&minutes)) + { + break; + } + + if prev_values.contains(&(hours, minutes)) { + return Err(RRuleError::new_iter_err( + "Infinite loop detected. It can be resolved by changing `BYMINUTE`, `BYHOUR` or `INTERVAL`", + )); + } + prev_values.insert((hours, minutes)); + } + + Ok(()) + } + + fn increment_secondly( + &mut self, + interval: u16, + by_hour: &[u8], + by_minute: &[u8], + by_second: &[u8], + increment_day: bool, + steps: &mut u32, + ) -> Result<(), RRuleError> { + if increment_day { + // Jump to one iteration before the next day + let total_seconds = self.hour * 3600 + self.minute * 60 + self.second; + let temp_value = (SECONDS_IN_A_DAY - 1 - total_seconds) / u32::from(interval); + self.second += checked_mul_u32( + temp_value, + u32::from(interval), + Some("please decrease `INTERVAL`"), + )?; + } + + let mut prev_values = HashSet::new(); + loop { + *steps = steps.saturating_add(1); + self.second += u32::from(interval); + let minutes_div = u16::try_from(self.second / 60).map_err(|_| { + RRuleError::new_iter_err("unexpected high second, please decrease interval") + })?; + if minutes_div > 0 { + self.second %= 60; + self.increment_minutely(minutes_div, by_hour, by_minute, increment_day, steps)?; + } + + let hours = (self.hour % 24) as u8; + let minutes = (self.minute % 60) as u8; + let seconds = (self.second % 60) as u8; + + if (by_hour.is_empty() || by_hour.contains(&hours)) + && (by_minute.is_empty() || by_minute.contains(&minutes)) + && (by_second.is_empty() || by_second.contains(&seconds)) + { + break; + } + + if prev_values.contains(&(hours, minutes, seconds)) { + return Err(RRuleError::new_iter_err( + "Infinite loop detected. It can be resolved by changing `BYSECOND`, `BYMINUTE`, `BYHOUR` or `INTERVAL`", + )); + } + prev_values.insert((hours, minutes, seconds)); + } + + Ok(()) + } +} + +impl From<&chrono::DateTime> for DateTimeIter { + fn from(dt: &chrono::DateTime) -> Self { + Self { + year: dt.year(), + month: dt.month(), + day: dt.day(), + hour: dt.hour(), + minute: dt.minute(), + second: dt.second(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + const UTC: Tz = Tz::Tz(chrono_tz::Tz::UTC); + + fn ymd_hms(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> DateTimeIter { + let dt = UTC + .with_ymd_and_hms(year, month, day, hour, min, sec) + .unwrap(); + DateTimeIter::from(&dt) + } + + #[test] + fn increments_counter_date_with_yearly_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1998, 1, 1, 0, 0, 0), + ), + ( + ymd_hms(2020, 2, 29, 0, 0, 0), + 1, + ymd_hms(2021, 3, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 3, + ymd_hms(2000, 1, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 20, + ymd_hms(2017, 1, 1, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Yearly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_monthly_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 2, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 11, + ymd_hms(1997, 12, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 12, + ymd_hms(1998, 1, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 12, 1, 0, 0, 0), + 1, + ymd_hms(1998, 1, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 11, 1, 0, 0, 0), + 12 * 10, + ymd_hms(2007, 11, 1, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Monthly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_weekly_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 6, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 13, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 5, + ymd_hms(1997, 2, 3, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 52, + ymd_hms(1997, 12, 29, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 53, + ymd_hms(1998, 1, 5, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Weekly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_daily_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 3, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 31, + ymd_hms(1997, 2, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 365, + ymd_hms(1998, 1, 1, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Daily, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_hourly_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + vec![], + ymd_hms(1997, 1, 1, 1, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + vec![], + ymd_hms(1997, 1, 1, 2, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + vec![4], + ymd_hms(1997, 1, 1, 4, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 10, 0, 0), + 1, + vec![4, 14], + ymd_hms(1997, 1, 1, 14, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 24, + vec![], + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 24 * 31, + vec![], + ymd_hms(1997, 2, 1, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, by_hour, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Hourly, + by_hour, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_hourly_freq_and_day_increment() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 5, + ymd_hms(1997, 1, 2, 1, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 24, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 24 * 31, + ymd_hms(1997, 2, 1, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 24 * 31 + 1, + ymd_hms(1997, 2, 1, 1, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Hourly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, true); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_minutely_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 1, 0, 1, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 1, 0, 2, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60, + ymd_hms(1997, 1, 1, 1, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60 * 24, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Minutely, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_minutely_freq_and_day_increment() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60 * 24 + 1, + ymd_hms(1997, 1, 2, 0, 1, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Minutely, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, true); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_secondly_freq() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 1, 0, 0, 1), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 1, 0, 0, 2), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60, + ymd_hms(1997, 1, 1, 0, 1, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60 * 60, + ymd_hms(1997, 1, 1, 1, 0, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Secondly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, false); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } + + #[test] + fn increments_counter_date_with_secondly_freq_and_day_increment() { + let tests = [ + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 1, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 2, + ymd_hms(1997, 1, 2, 0, 0, 0), + ), + ( + ymd_hms(1997, 1, 1, 0, 0, 0), + 60 * 24 + 1, + ymd_hms(1997, 1, 2, 0, 1, 0), + ), + ]; + for (mut counter_date, interval, expected_output) in tests { + let rrule = RRule { + interval, + freq: ICalendarFrequency::Secondly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap()) + .unwrap(); + + let res = counter_date.increment(&rrule, true); + assert!(res.is_ok()); + assert_eq!(counter_date, expected_output); + } + } +} diff --git a/vendor/calcard/src/datecalc/easter.rs b/vendor/calcard/src/datecalc/easter.rs new file mode 100644 index 00000000..22997264 --- /dev/null +++ b/vendor/calcard/src/datecalc/easter.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::masks::MASKS; +use super::utils::is_leap_year; + +/// Implementation of the Gauss Easter Algorithm. +#[allow(clippy::many_single_char_names)] +pub(crate) fn easter(year: i32, offset: i16) -> i32 { + let a = year % 19; + let b = year / 100; + let c = year % 100; + let d = b / 4; + let e = b % 4; + let f = (b + 8) / 25; + let g = (b - f + 1) / 3; + let h = (19 * a + b - d - g + 15) % 30; + let i = c / 4; + let k = c % 4; + let l = (32 + 2 * e + 2 * i - h - k) % 7; + let m = (a + 11 * h + 22 * l) / 451; + let month = ((h + l - 7 * m + 114) / 31) as usize; + let day = ((h + l - 7 * m + 114) % 31) + 1 + i32::from(offset); + + let month_range_mask = if is_leap_year(year) { + &MASKS.month_366_range + } else { + &MASKS.month_365_range + }; + + i32::from(month_range_mask[month - 1]) + day - 1 +} + +#[cfg(test)] +mod test_easter_masks { + use super::*; + + #[test] + fn easter_mask() { + let easter_day = easter(1997, 0); + assert_eq!(easter_day, 88); + let easter_day = easter(1998, 0); + assert_eq!(easter_day, 101); + let easter_day = easter(1999, 0); + assert_eq!(easter_day, 93); + let easter_day = easter(2000, 0); + assert_eq!(easter_day, 113); + } +} diff --git a/vendor/calcard/src/datecalc/error.rs b/vendor/calcard/src/datecalc/error.rs new file mode 100644 index 00000000..860227de --- /dev/null +++ b/vendor/calcard/src/datecalc/error.rs @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{common::IanaString, icalendar::ICalendarFrequency}; +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub enum RRuleError { + ValidationError(ValidationError), + IterError(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub enum ValidationError { + BySetPosWithoutByRule, + InvalidFieldValue { + field: String, + value: String, + }, + InvalidFieldValueRange { + field: String, + value: String, + start_idx: String, + end_idx: String, + }, + InvalidFieldValueRangeWithFreq { + field: String, + value: String, + freq: ICalendarFrequency, + start_idx: String, + end_idx: String, + }, + InvalidByRuleAndFrequency { + by_rule: String, + freq: ICalendarFrequency, + }, + UntilBeforeStart { + until: String, + dt_start: String, + }, + TooBigInterval(u16), + StartYearOutOfRange(i32), + UnableToGenerateTimeset, + InvalidByRuleWithByEaster, + DtStartUntilMismatchTimezone { + dt_start_tz: Cow<'static, str>, + until_tz: Cow<'static, str>, + expected: Vec>, + }, +} + +impl From for RRuleError { + fn from(err: ValidationError) -> Self { + Self::ValidationError(err) + } +} + +impl From for RRuleError { + fn from(err: String) -> Self { + Self::IterError(err) + } +} + +impl RRuleError { + pub fn new_iter_err>(msg: S) -> Self { + Self::IterError(msg.as_ref().to_owned()) + } +} + +pub(crate) fn checked_mul_u32(v1: u32, v2: u32, hint: Option<&str>) -> Result { + v1.checked_mul(v2).ok_or_else(|| match hint { + Some(hint) => RRuleError::new_iter_err(format!( + "Could not multiply number, would overflow (`{} * {}`), {}.", + v1, v2, hint + )), + None => RRuleError::new_iter_err(format!( + "Could not multiply number, would overflow (`{} * {}`).", + v1, v2, + )), + }) +} + +pub(crate) fn checked_add_u32(v1: u32, v2: u32, hint: Option<&str>) -> Result { + v1.checked_add(v2).ok_or_else(|| match hint { + Some(hint) => RRuleError::new_iter_err(format!( + "Could not add numbers, would overflow (`{} + {}`), {}.", + v1, v2, hint + )), + None => RRuleError::new_iter_err(format!( + "Could not add numbers, would overflow (`{} + {}`).", + v1, v2, + )), + }) +} + +impl Display for RRuleError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RRuleError::ValidationError(err) => write!(f, "{}", err), + RRuleError::IterError(err) => write!(f, "Iteration error: {}", err), + } + } +} + +impl Display for ValidationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::BySetPosWithoutByRule => { + write!(f, "BYSETPOS without BYxxx rule is not allowed") + } + ValidationError::InvalidFieldValue { field, value } => { + write!(f, "Invalid value `{value}` for field `{field}`") + } + ValidationError::InvalidFieldValueRange { + field, + value, + start_idx, + end_idx, + } => write!( + f, + "Invalid value `{value}` for field `{field}`, start index `{start_idx}` and end index `{end_idx}`" + ), + ValidationError::InvalidFieldValueRangeWithFreq { + field, + value, + freq, + start_idx, + end_idx, + } => write!( + f, + "Invalid value `{value}` for field `{field}`, frequency `{}`, start index `{start_idx}` and end index `{end_idx}`", + freq.as_str() + ), + ValidationError::InvalidByRuleAndFrequency { by_rule, freq } => write!( + f, + "Invalid BY rule `{by_rule}` with frequency `{}`", + freq.as_str() + ), + ValidationError::UntilBeforeStart { until, dt_start } => write!( + f, + "Until date `{until}` is before the start date `{dt_start}`" + ), + ValidationError::TooBigInterval(interval) => write!( + f, + "Interval of {interval} is too big. The maximum interval is 32767." + ), + ValidationError::StartYearOutOfRange(year) => write!( + f, + "Start year {year} is out of range. The valid range is 1970 to 2038." + ), + ValidationError::UnableToGenerateTimeset => { + write!(f, "Unable to generate timeset") + } + ValidationError::InvalidByRuleWithByEaster => { + write!(f, "BYEASTER cannot be used with BYxxx rules") + } + ValidationError::DtStartUntilMismatchTimezone { + dt_start_tz, + until_tz, + expected, + } => write!( + f, + "DTSTART timezone `{dt_start_tz}` does not match UNTIL timezone `{until_tz}`, expected timezones: {:?}", + expected + ), + } + } +} diff --git a/vendor/calcard/src/datecalc/filters.rs b/vendor/calcard/src/datecalc/filters.rs new file mode 100644 index 00000000..a292be49 --- /dev/null +++ b/vendor/calcard/src/datecalc/filters.rs @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + iterinfo::IterInfo, + rrule::{NWeekday, RRule}, +}; +use crate::icalendar::ICalendarMonth; + +type RRuleFilter = &'static dyn Fn(&IterInfo, usize, &RRule) -> bool; + +const FILTERS: [RRuleFilter; 7] = [ + &is_filtered_by_month, + &is_filtered_by_week_number, + &is_filtered_by_weekday, + &is_filtered_by_neg_weekday, + &is_filtered_by_easter, + &is_filtered_by_month_day, + &is_filtered_by_year_day, +]; + +pub(crate) fn is_filtered(ii: &IterInfo, current_day: usize) -> bool { + let rrule = ii.rrule(); + FILTERS + .into_iter() + .any(|filter| filter(ii, current_day, rrule)) +} + +fn is_filtered_by_month(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + if rrule.by_month.is_empty() { + return false; + } + + let current_month = ICalendarMonth::new(ii.month_mask()[current_day], false); + !rrule.by_month.contains(¤t_month) +} + +fn is_filtered_by_week_number(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + if rrule.by_week_no.is_empty() { + return false; + } + + matches!(ii.week_no_mask(), Some(week_no_mask) if week_no_mask[current_day] == 0) +} + +fn is_filtered_by_weekday(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + let mut by_weekday_every_week_only = rrule + .by_weekday + .iter() + .filter_map(|by_weekday| match by_weekday { + // Get only `Every` occurrences. + NWeekday::Every(weekday) => Some(weekday.num_days_from_monday()), + NWeekday::Nth(_, _) => None, + }) + .peekable(); + + // Check if empty + if by_weekday_every_week_only.peek().is_none() { + return false; + } + + let current_weekday = ii.weekday_mask()[current_day]; + !by_weekday_every_week_only.any(|el| el == current_weekday) +} + +fn is_filtered_by_neg_weekday(ii: &IterInfo, current_day: usize, _rrule: &RRule) -> bool { + if let Some(neg_weekday_mask) = ii.neg_weekday_mask() { + if neg_weekday_mask.is_empty() { + return false; + } + + let current_neg_weekday = neg_weekday_mask[current_day]; + current_neg_weekday == 0 + } else { + false + } +} + +fn is_filtered_by_easter(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + if rrule.by_easter.is_none() { + return false; + } + match i32::try_from(current_day) { + Ok(current_day) => { + !matches!(ii.easter_mask(), Some(easter_mask) if easter_mask.contains(¤t_day)) + } + _ => true, + } +} + +fn is_filtered_by_month_day(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + if rrule.by_month_day.is_empty() && rrule.by_n_month_day.is_empty() { + return false; + } + + let current_month_day = ii.month_day_mask()[current_day]; + let current_n_month_day = ii.neg_month_day_mask()[current_day]; + let filtered_by_month_day = !rrule.by_month_day.contains(¤t_month_day); + let filtered_by_n_month_day = !rrule.by_n_month_day.contains(¤t_n_month_day); + + filtered_by_month_day && filtered_by_n_month_day +} + +fn is_filtered_by_year_day(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool { + if rrule.by_year_day.is_empty() { + return false; + } + + let current_day = match i16::try_from(current_day) { + Ok(current_day) => current_day, + _ => return true, + }; + + let year_len = (ii.year_len()) as i16; + let next_year_len = (ii.next_year_len()) as i16; + + if current_day < year_len { + !rrule.by_year_day.contains(&(current_day + 1)) + && !rrule.by_year_day.contains(&(current_day - year_len)) + } else { + !rrule.by_year_day.contains(&(current_day + 1 - year_len)) + && !rrule + .by_year_day + .contains(&(current_day - next_year_len - year_len)) + } +} diff --git a/vendor/calcard/src/datecalc/iterinfo.rs b/vendor/calcard/src/datecalc/iterinfo.rs new file mode 100644 index 00000000..95794289 --- /dev/null +++ b/vendor/calcard/src/datecalc/iterinfo.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::counter_date::DateTimeIter; +use super::easter::easter; +use super::get_month; +use super::rrule::{NWeekday, RRule}; +use super::{monthinfo::MonthInfo, yearinfo::YearInfo}; +use crate::common::timezone::Tz; +use crate::icalendar::ICalendarFrequency; +use chrono::{Datelike, NaiveTime, TimeZone}; + +#[derive(Debug, Clone)] +pub(crate) struct IterInfo { + year_info: YearInfo, + month_info: Option, + easter_mask: Option>, + rrule: RRule, +} + +impl IterInfo { + pub fn new(rrule: &RRule, dt_start: &chrono::DateTime) -> Self { + let year = dt_start.year(); + let month = get_month(dt_start); + + let year_info = YearInfo::new(year, rrule); + let mut ii = Self { + rrule: rrule.clone(), + year_info, + month_info: None, + easter_mask: None, + }; + ii.rebuild_inner(year, month, true); + + ii + } + + fn rebuild_inner(&mut self, year: i32, month: u8, skip_year_info: bool) { + if !skip_year_info + && !matches!(&self.month_info, Some(month_info) if month_info.last_year == year) + { + self.year_info = YearInfo::new(year, &self.rrule); + } + + let contains_nth_by_weekday = self + .rrule + .by_weekday + .iter() + .any(|by_weekday| matches!(by_weekday, NWeekday::Nth(_, _))); + + if contains_nth_by_weekday + && !(matches!(&self.month_info, Some(month_info) if month_info.last_month == month && month_info.last_year == year)) + { + let new_month_info = MonthInfo::new(&self.year_info, month, &self.rrule); + self.month_info = Some(new_month_info); + } + + self.easter_mask = self + .rrule + .by_easter + .map(|by_easter| vec![easter(year, by_easter)]); + } + + pub fn rebuild(&mut self, counter_date: &DateTimeIter) { + self.rebuild_inner(counter_date.year, counter_date.month as u8, false); + } + + pub fn year_len(&self) -> u16 { + self.year_info.year_len + } + + pub fn year_ordinal(&self) -> i64 { + self.year_info.year_ordinal + } + + pub fn month_range(&self) -> &[u16] { + self.year_info.month_range + } + + pub fn easter_mask(&self) -> Option<&Vec> { + self.easter_mask.as_ref() + } + + pub fn weekday_mask(&self) -> &[u32] { + self.year_info.weekday_mask + } + + pub fn month_mask(&self) -> &[u8] { + self.year_info.month_mask + } + + pub fn week_no_mask(&self) -> Option<&Vec> { + self.year_info.week_no_mask.as_ref() + } + + pub fn neg_weekday_mask(&self) -> Option<&Vec> { + self.month_info.as_ref().map(|info| &info.neg_weekday_mask) + } + + pub fn next_year_len(&self) -> u16 { + self.year_info.next_year_len + } + + pub fn month_day_mask(&self) -> &[i8] { + self.year_info.month_day_mask + } + + pub fn neg_month_day_mask(&self) -> &[i8] { + self.year_info.neg_month_day_mask + } + + pub fn year_dayset(&self) -> Vec { + let year_len = usize::from(self.year_len()); + (0..year_len).collect() + } + + pub fn month_dayset(&self, month: u32) -> Vec { + let month_range = self.month_range(); + let month = month as usize; + let start = usize::from(month_range[month - 1]); + let end = usize::from(month_range[month]); + (start..end).collect() + } + + pub fn weekday_set(&self, year: i32, month: u32, day: u32) -> Vec { + let set_len = usize::from(self.year_len() + 7); + + let mut date_ordinal = chrono::Utc + .with_ymd_and_hms(year, month, day, 0, 0, 0) + .unwrap() + .ordinal0() as usize; + + let mut set = vec![]; + + let week_start_num_days_from_monday = self.rrule.week_start.num_days_from_monday(); + + for _ in 0..7 { + if date_ordinal >= set_len { + break; + } + set.push(date_ordinal); + date_ordinal += 1; + if self.weekday_mask()[date_ordinal] == week_start_num_days_from_monday { + break; + } + } + + set + } + + pub fn day_dayset(year: i32, month: u32, day: u32) -> Vec { + let date_ordinal = chrono::Utc + .with_ymd_and_hms(year, month, day, 0, 0, 0) + .unwrap() + .ordinal0(); + + vec![date_ordinal as usize] + } + + pub fn hour_timeset(&self, hour: u8) -> Vec { + self.rrule + .by_minute + .iter() + .flat_map(|minute| self.min_timeset(hour, *minute)) + .collect() + } + + pub fn min_timeset(&self, hour: u8, minute: u8) -> Vec { + self.rrule + .by_second + .iter() + .filter_map(|second| { + NaiveTime::from_hms_opt(u32::from(hour), u32::from(minute), u32::from(*second)) + }) + .collect() + } + + pub fn sec_timeset(hour: u8, minute: u8, second: u8) -> Vec { + if let Some(time) = + NaiveTime::from_hms_opt(u32::from(hour), u32::from(minute), u32::from(second)) + { + vec![time] + } else { + vec![] + } + } + + pub fn get_dayset( + &self, + freq: ICalendarFrequency, + year: i32, + month: u32, + day: u32, + ) -> Vec { + let mut dayset = match freq { + ICalendarFrequency::Yearly => self.year_dayset(), + ICalendarFrequency::Monthly => self.month_dayset(month), + ICalendarFrequency::Weekly => self.weekday_set(year, month, day), + _ => Self::day_dayset(year, month, day), + }; + + // Filter out days according to the RRule filters. + dayset.retain(|day| !super::filters::is_filtered(self, *day)); + + dayset + } + + /// Gets a timeset without checking if the hour, minute and second are valid, according + /// to the `RRule`. + /// + /// This is usually called after calling the `increment_counter_date` where we know + /// that we get a valid `DateTime` back, and there is no need to do any duplicate + /// validation. + pub fn get_timeset_unchecked(&self, hour: u8, minute: u8, second: u8) -> Vec { + match self.rrule.freq { + ICalendarFrequency::Hourly => self.hour_timeset(hour), + ICalendarFrequency::Minutely => self.min_timeset(hour, minute), + ICalendarFrequency::Secondly => Self::sec_timeset(hour, minute, second), + _ => unreachable!( + "This method is never called with an invalid frequency and is not publicly exposed" + ), + } + } + + /// Gets a timeset. + /// + /// An empty set is returned if the hour, minute and second aren't valid, + /// according to the `RRule`. + pub fn get_timeset(&self, hour: u8, minute: u8, second: u8) -> Vec { + match self.rrule.freq { + ICalendarFrequency::Hourly + | ICalendarFrequency::Minutely + | ICalendarFrequency::Secondly => { + let incorrect_hour = self.rrule.freq >= ICalendarFrequency::Hourly + && !self.rrule.by_hour.is_empty() + && !self.rrule.by_hour.contains(&hour); + let incorrect_minute = self.rrule.freq >= ICalendarFrequency::Minutely + && !self.rrule.by_minute.is_empty() + && !self.rrule.by_minute.contains(&minute); + let incorrect_second = self.rrule.freq >= ICalendarFrequency::Secondly + && !self.rrule.by_second.is_empty() + && !self.rrule.by_second.contains(&second); + let date_is_not_a_candidate = + incorrect_hour || incorrect_minute || incorrect_second; + + // If date is not a potential candidate, then we return an empty timeset. + if date_is_not_a_candidate { + return vec![]; + } + + self.get_timeset_unchecked(hour, minute, second) + } + _ => self + .rrule + .by_hour + .iter() + .flat_map(|hour| { + self.rrule.by_minute.iter().flat_map(move |minute| { + self.rrule.by_second.iter().filter_map(move |second| { + NaiveTime::from_hms_opt( + u32::from(*hour), + u32::from(*minute), + u32::from(*second), + ) + }) + }) + }) + .collect(), + } + } + + pub fn rrule(&self) -> &RRule { + &self.rrule + } +} diff --git a/vendor/calcard/src/datecalc/masks.rs b/vendor/calcard/src/datecalc/masks.rs new file mode 100644 index 00000000..0f491898 --- /dev/null +++ b/vendor/calcard/src/datecalc/masks.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +// Every mask is 7 days longer to handle cross-year weekly periods. + +pub(crate) type WdayMask = [u32; 7 * 55]; +pub(crate) type Mask365 = [u8; 365 + 7]; +pub(crate) type Mask366 = [u8; 366 + 7]; +pub(crate) type Mask365Signed = [i8; 365 + 7]; +pub(crate) type Mask366Signed = [i8; 366 + 7]; +pub(crate) type MonthRangeMask = [u16; 13]; + +const M365: Mask365 = [ + // 31 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + // 28 + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + // 31 + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + // 30 + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + // 31 + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + // 30 + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + // 31 + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + // 31 + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + // 30 + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + // 31 + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, + // 30 ----------------------------------------------------------------------------------------- + 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 11, 11, 11, + // 31 ----------------------------------------------------------------------------------------- + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, + // 7 ------------------------------------------------------------------------------------------ + 1, 1, 1, 1, 1, 1, 1, +]; +const MDAY365: Mask365Signed = [ + // 31 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 28 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 7 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, +]; +const NMDAY365: Mask365Signed = [ + // 31 + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 28 ----------------------------------------------------------------------------------------- + -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, + -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 ----------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 ----------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 7 ----------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, +]; +const M365_RANGE: MonthRangeMask = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + +const M366: Mask366 = [ + // 31 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + // 29 + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + // 31 + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + // 30 + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + // 31 + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + // 30 + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + // 31 + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + // 31 + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + // 30 + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + // 31 + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, + // 30 ----------------------------------------------------------------------------------------- + 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 11, 11, 11, + // 31 ----------------------------------------------------------------------------------------- + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, + // 7 ------------------------------------------------------------------------------------------ + 1, 1, 1, 1, 1, 1, 1, +]; +const MDAY366: Mask366Signed = [ + // 31 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 29 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 --------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 30 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, + // 31 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, + // 7 ----------------------------------------------------------------------------------------- + 1, 2, 3, 4, 5, 6, 7, +]; +const M366_RANGE: MonthRangeMask = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; +const NMDAY366: Mask366Signed = [ + // 31 + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 29 ----------------------------------------------------------------------------------------- + -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, + -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 --------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 --------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 30 ----------------------------------------------------------------------------------------- + -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, + -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 31 ----------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + // 7 ----------------------------------------------------------------------------------------- + -31, -30, -29, -28, -27, -26, -25, +]; +const WDAY: WdayMask = [ + 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, + 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, + 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, + 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, + 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, + 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, + 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, + 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, + 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, + 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, + 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, + 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, + 6, +]; + +#[derive(Clone)] +pub(crate) struct Masks { + pub weekday: WdayMask, + pub month_365: Mask365, + pub month_365_range: MonthRangeMask, + pub month_366: Mask366, + pub month_366_range: MonthRangeMask, + pub month_day_365: Mask365Signed, + pub month_day_366: Mask366Signed, + pub neg_month_day_365: Mask365Signed, + pub neg_month_day_366: Mask366Signed, +} + +pub(crate) const MASKS: Masks = Masks { + weekday: WDAY, + month_365: M365, + month_365_range: M365_RANGE, + month_366: M366, + month_366_range: M366_RANGE, + month_day_365: MDAY365, + month_day_366: MDAY366, + neg_month_day_365: NMDAY365, + neg_month_day_366: NMDAY366, +}; diff --git a/vendor/calcard/src/datecalc/mod.rs b/vendor/calcard/src/datecalc/mod.rs new file mode 100644 index 00000000..59f4451d --- /dev/null +++ b/vendor/calcard/src/datecalc/mod.rs @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +mod checks; +mod counter_date; +mod easter; +pub mod error; +pub(crate) mod filters; +pub(crate) mod iterinfo; +mod masks; +mod monthinfo; +mod pos_list; +pub mod rrule; +pub(crate) mod rrule_iter; +mod utils; +mod validate; +mod yearinfo; + +use crate::common::timezone::Tz; +use chrono::{Datelike, Duration, NaiveTime, Timelike}; +use iterinfo::IterInfo; +use pos_list::build_pos_list; +pub(crate) use rrule_iter::RRuleIter; + +/// Prevent loops when searching for the next event in the iterator. +/// If after X number of iterations it still has not found an event, +/// we can assume it will not find an event. +static MAX_ITER_LOOP: u32 = 100_000; + +/// inbuxa: the work one `expand_dates` call may spend on all its recurrence +/// rules together. Every pass over a period costs one unit, a pass that finds +/// nothing costs one per 16 days it scanned, candidates a pass looks at +/// without producing them cost one each, and so does every time the search +/// for the next `BYHOUR`/`BYMINUTE`/`BYSECOND` match skips one. Rules that +/// rarely or never match, spread over many components, can't add up past this +/// (about 50 ms of work in a release build). +pub(crate) const MAX_EXPANSION_WORK: u32 = 1_000_000; + +#[inline(always)] +pub(crate) fn duration_from_midnight(time: NaiveTime) -> Duration { + Duration::hours(i64::from(time.hour())) + + Duration::minutes(i64::from(time.minute())) + + Duration::seconds(i64::from(time.second())) +} + +#[inline(always)] +pub(crate) fn get_month(dt: &chrono::DateTime) -> u8 { + dt.month() as u8 +} + +#[inline(always)] +pub(crate) fn get_day(dt: &chrono::DateTime) -> i8 { + dt.day() as i8 +} + +#[inline(always)] +pub(crate) fn get_hour(dt: &chrono::DateTime) -> u8 { + dt.hour() as u8 +} + +#[inline(always)] +pub(crate) fn get_minute(dt: &chrono::DateTime) -> u8 { + dt.minute() as u8 +} + +#[inline(always)] +pub(crate) fn get_second(dt: &chrono::DateTime) -> u8 { + dt.second() as u8 +} diff --git a/vendor/calcard/src/datecalc/monthinfo.rs b/vendor/calcard/src/datecalc/monthinfo.rs new file mode 100644 index 00000000..20e10a2b --- /dev/null +++ b/vendor/calcard/src/datecalc/monthinfo.rs @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + rrule::{NWeekday, RRule}, + utils::pymod, + yearinfo::YearInfo, +}; +use crate::icalendar::ICalendarFrequency; + +#[derive(Debug, Clone)] +pub(crate) struct MonthInfo { + pub last_year: i32, + pub last_month: u8, + pub neg_weekday_mask: Vec, +} + +impl MonthInfo { + pub fn new(year_info: &YearInfo, month: u8, rrule: &RRule) -> Self { + let neg_weekday_mask = Self::get_neg_weekday_mask(year_info, month, rrule); + Self { + last_year: year_info.year, + last_month: month, + neg_weekday_mask, + } + } + + fn get_neg_weekday_mask(year_info: &YearInfo, month: u8, rrule: &RRule) -> Vec { + let YearInfo { + year_len, + month_range, + weekday_mask, + .. + } = year_info; + + // Build up `ranges` + let mut ranges = vec![]; + if rrule.freq == ICalendarFrequency::Yearly { + if rrule.by_month.is_empty() { + ranges.push((0, u32::from(*year_len) - 1)); + } else { + for month in &rrule.by_month { + let month = usize::from(month.month()); + let first = u32::from(month_range[month - 1]); + let last = u32::from(month_range[month]); + ranges.push((first, last - 1)); + } + } + } else if rrule.freq == ICalendarFrequency::Monthly { + let month = usize::from(month); + let first = u32::from(month_range[month - 1]); + let last = u32::from(month_range[month]); + ranges.push((first, last - 1)); + } + + if ranges.is_empty() { + return vec![]; + } + + // Weekly frequency won't get here, so we may not + // care about cross-year weekly periods. + let mut neg_weekday_mask = vec![0; usize::from(*year_len)]; + + // Loop over `ranges` + for (first, last) in ranges { + for by_weekday in &rrule.by_weekday { + // Only check Nth occurrences here + if let NWeekday::Nth(number, weekday) = by_weekday { + let weekday = (weekday.num_days_from_monday()) as i16; + let nth_weekday = if *number < 0 { + let number = match u32::try_from(-number) { + Ok(num) => num, + _ => continue, + }; + let nth_last_week = match last.checked_sub((number - 1) * 7) { + Some(val) => val, + None => continue, + }; + let index = match usize::try_from(nth_last_week) { + Ok(i) => i, + _ => continue, + }; + let nth_last_weekday = match weekday_mask.get(index) { + Some(val) => (*val) as i16, + None => continue, + }; + + // Adjust to get the correct weekday + let modulo = (pymod(nth_last_weekday - weekday, 7)) as u32; + match nth_last_week.checked_sub(modulo) { + Some(val) => val, + None => continue, + } + } else { + let number = match u32::try_from(number - 1) { + Ok(num) => num, + _ => continue, + }; + let nth_first_day = first + number * 7; + let index = match usize::try_from(nth_first_day) { + Ok(i) => i, + _ => continue, + }; + let nth_first_day_weekday = match weekday_mask.get(index) { + Some(val) => (*val) as i16, + None => continue, + }; + + // Adjust to get the correct weekday + let a = (7 - nth_first_day_weekday + weekday) as u32; + nth_first_day + pymod(a, 7) + }; + if first <= nth_weekday + && nth_weekday <= last + && let Ok(nth_weekday) = usize::try_from(nth_weekday) + { + neg_weekday_mask[nth_weekday] = 1; + } + } + } + } + + neg_weekday_mask + } +} + +#[cfg(test)] +mod tests { + use crate::common::timezone::Tz; + + use super::*; + use chrono::{TimeZone, Weekday}; + + const UTC: Tz = Tz::Tz(chrono_tz::Tz::UTC); + + #[test] + fn get_neg_weekday_mask_with_daily_freq() { + let rrule = RRule { + freq: ICalendarFrequency::Daily, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap()) + .unwrap(); + + let year_info = YearInfo::new(1997, &rrule); + + let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule); + assert!(neg_weekday_mask.is_empty()); + } + + #[test] + fn get_neg_weekday_mask_with_yearly_freq() { + let rrule = RRule { + freq: ICalendarFrequency::Yearly, + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap()) + .unwrap(); + + let year_info = YearInfo::new(1997, &rrule); + + let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule); + assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize); + assert!(neg_weekday_mask.into_iter().all(|val| val == 0)); + } + + #[test] + fn get_neg_weekday_mask_with_yearly_freq_and_byweekday() { + let rrule = RRule { + freq: ICalendarFrequency::Yearly, + by_weekday: vec![ + NWeekday::new(None, Weekday::Mon), + NWeekday::new(Some(-2), Weekday::Thu), + NWeekday::new(Some(1), Weekday::Thu), + ], + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap()) + .unwrap(); + + let year_info = YearInfo::new(1997, &rrule); + + let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule); + assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize); + assert!( + neg_weekday_mask + .into_iter() + .enumerate() + .all(|(idx, val)| match idx { + 1 | 351 => val == 1, + _ => val == 0, + }) + ); + } + + #[test] + fn get_neg_weekday_mask_with_monthly_freq_and_byweekday() { + let rrule = RRule { + freq: ICalendarFrequency::Monthly, + by_weekday: vec![ + NWeekday::new(None, Weekday::Mon), + NWeekday::new(Some(-2), Weekday::Thu), + NWeekday::new(Some(1), Weekday::Thu), + ], + ..Default::default() + } + .validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap()) + .unwrap(); + + let year_info = YearInfo::new(1997, &rrule); + + let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule); + assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize); + assert!( + neg_weekday_mask + .into_iter() + .enumerate() + .all(|(idx, val)| match idx { + 1 | 22 => val == 1, + _ => val == 0, + }) + ); + } +} diff --git a/vendor/calcard/src/datecalc/pos_list.rs b/vendor/calcard/src/datecalc/pos_list.rs new file mode 100644 index 00000000..7ac89586 --- /dev/null +++ b/vendor/calcard/src/datecalc/pos_list.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::utils::{add_time_to_date, date_from_ordinal, pymod}; +use crate::common::timezone::Tz; +use chrono::NaiveTime; + +pub(crate) fn build_pos_list( + by_set_pos: &[i32], + dayset: &[usize], + timeset: &[NaiveTime], + year_ordinal: i64, + tz: Tz, +) -> Vec> { + let mut pos_list = vec![]; + + if timeset.is_empty() { + return vec![]; + } + + let timeset_len = (timeset.len()) as u32; + let timeset_len_float = f64::from(timeset_len); + let timeset_len_int = timeset_len as i32; + for pos in by_set_pos { + let pos = if *pos > 0 { pos - 1 } else { *pos }; + let day_pos = (f64::from(pos) / timeset_len_float).floor() as isize; + let time_pos = (pymod(pos, timeset_len_int)) as usize; + + let day_idx = if day_pos < 0 { + let dayset_len = dayset.len() as isize; + let index = dayset_len + day_pos; + match usize::try_from(index) { + Ok(day_idx) => day_idx, + Err(_) => continue, + } + } else { + (day_pos) as usize + }; + let day = match dayset.get(day_idx) { + Some(day) => day, + None => continue, + }; + let day = *day as i64; + + // Get ordinal which is UTC + let date = date_from_ordinal(year_ordinal + day); + // Create new Date + Time combination + // Use Time from `timeset`. + let time = timeset[time_pos]; + let res = match add_time_to_date(tz, date, time) { + Some(date) => date, + None => continue, + }; + + if !pos_list.contains(&res) { + pos_list.push(res); + } + } + + pos_list.sort(); + + pos_list +} diff --git a/vendor/calcard/src/datecalc/rrule.rs b/vendor/calcard/src/datecalc/rrule.rs new file mode 100644 index 00000000..44f686fd --- /dev/null +++ b/vendor/calcard/src/datecalc/rrule.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + error::{RRuleError, ValidationError}, + get_day, get_hour, get_minute, get_month, get_second, + validate::validate_rrule_forced, +}; +use crate::{ + common::timezone::Tz, + icalendar::{ICalendarFrequency, ICalendarMonth, ICalendarRecurrenceRule}, +}; +use chrono::{DateTime, Datelike, Weekday}; +use std::cmp::Ordering; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RRule { + pub(crate) freq: ICalendarFrequency, + pub(crate) interval: u16, + pub(crate) count: Option, + pub(crate) until: Option>, + pub(crate) week_start: Weekday, + pub(crate) by_set_pos: Vec, + pub(crate) by_month: Vec, + pub(crate) by_month_day: Vec, + pub(crate) by_n_month_day: Vec, + pub(crate) by_year_day: Vec, + pub(crate) by_week_no: Vec, + pub(crate) by_weekday: Vec, + pub(crate) by_hour: Vec, + pub(crate) by_minute: Vec, + pub(crate) by_second: Vec, + pub(crate) by_easter: Option, +} + +impl RRule { + pub fn from_floating_ical(ical: &ICalendarRecurrenceRule) -> Option { + Some(RRule { + freq: ical.freq, + interval: ical.interval.unwrap_or(1), + count: ical.count, + until: if let Some(until) = &ical.until { + until.to_date_time_with_tz(Tz::Floating)?.into() + } else { + None + }, + week_start: ical.wkst.map(Into::into).unwrap_or(Weekday::Mon), + by_set_pos: ical.bysetpos.clone(), + by_month: ical.bymonth.clone(), + by_month_day: ical + .bymonthday + .iter() + .filter(|v| **v > 0) + .copied() + .collect(), + by_n_month_day: ical + .bymonthday + .iter() + .filter(|v| **v < 0) + .copied() + .collect(), + by_year_day: ical.byyearday.clone(), + by_week_no: ical.byweekno.clone(), + by_weekday: ical + .byday + .iter() + .map(|wday| NWeekday::new(wday.ordwk, wday.weekday.into())) + .collect(), + by_hour: ical.byhour.clone(), + by_minute: ical.byminute.clone(), + by_second: ical.bysecond.clone(), + by_easter: None, + }) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum NWeekday { + Every(Weekday), + Nth(i16, Weekday), +} + +impl RRule { + pub(crate) fn finalize_parsed_rrule(mut self, dt_start: &DateTime) -> Self { + // Can only be set to true if the feature flag is set. + let by_easter_is_some = self.by_easter.is_some(); + + // Add some freq-specific additional properties + if !(!self.by_week_no.is_empty() + || !self.by_year_day.is_empty() + || !self.by_month_day.is_empty() + || !self.by_n_month_day.is_empty() + || !self.by_weekday.is_empty() + || by_easter_is_some) + { + match self.freq { + ICalendarFrequency::Yearly => { + if self.by_month.is_empty() { + let month = get_month(dt_start); + self.by_month = vec![ICalendarMonth::new(month, false)]; + } + let day = get_day(dt_start); + self.by_month_day = vec![day]; + } + ICalendarFrequency::Monthly => { + let day = get_day(dt_start); + self.by_month_day = vec![day]; + } + ICalendarFrequency::Weekly => { + self.by_weekday = vec![NWeekday::Every(dt_start.weekday())]; + } + _ => (), + }; + } + + // by_hour + if self.by_hour.is_empty() && self.freq < ICalendarFrequency::Hourly { + let hour = get_hour(dt_start); + self.by_hour = vec![hour]; + } + + // by_minute + if self.by_minute.is_empty() && self.freq < ICalendarFrequency::Minutely { + let minute = get_minute(dt_start); + self.by_minute = vec![minute]; + } + + // by_second + if self.by_second.is_empty() && self.freq < ICalendarFrequency::Secondly { + let second = get_second(dt_start); + self.by_second = vec![second]; + } + + // make sure all BYXXX are unique and sorted + self.by_hour.sort_unstable(); + self.by_hour.dedup(); + + self.by_minute.sort_unstable(); + self.by_minute.dedup(); + + self.by_second.sort_unstable(); + self.by_second.dedup(); + + self.by_month.sort_unstable(); + self.by_month.dedup(); + + self.by_month_day.sort_unstable(); + self.by_month_day.dedup(); + + self.by_n_month_day.sort_unstable(); + self.by_n_month_day.dedup(); + + self.by_year_day.sort_unstable(); + self.by_year_day.dedup(); + + self.by_week_no.sort_unstable(); + self.by_week_no.dedup(); + + self.by_set_pos.sort_unstable(); + self.by_set_pos.dedup(); + + self.by_weekday.sort_unstable(); + self.by_weekday.dedup(); + + self + } + + pub fn validate(self, dt_start: DateTime) -> Result { + let rrule = self.finalize_parsed_rrule(&dt_start); + + // Validate required checks (defined by RFC 5545) + validate_rrule_forced(&rrule, &dt_start)?; + + // Check if it is possible to generate a timeset + match rrule.freq { + ICalendarFrequency::Hourly => { + if rrule.by_minute.is_empty() && rrule.by_second.is_empty() { + return Err(ValidationError::UnableToGenerateTimeset.into()); + } + } + ICalendarFrequency::Minutely => { + if rrule.by_second.is_empty() { + return Err(ValidationError::UnableToGenerateTimeset.into()); + } + } + ICalendarFrequency::Secondly => {} + _ => { + if rrule.by_hour.is_empty() + && rrule.by_minute.is_empty() + && rrule.by_second.is_empty() + { + return Err(ValidationError::UnableToGenerateTimeset.into()); + } + } + } + + Ok(rrule) + } +} + +impl Default for RRule { + fn default() -> Self { + Self { + freq: ICalendarFrequency::Yearly, + interval: 1, + count: None, + until: None, + week_start: Weekday::Mon, + by_set_pos: Vec::new(), + by_month: Vec::new(), + by_month_day: Vec::new(), + by_n_month_day: Vec::new(), + by_year_day: Vec::new(), + by_week_no: Vec::new(), + by_weekday: Vec::new(), + by_hour: Vec::new(), + by_minute: Vec::new(), + by_second: Vec::new(), + by_easter: None, + } + } +} + +impl PartialOrd for NWeekday { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for NWeekday { + fn cmp(&self, other: &Self) -> Ordering { + n_weekday_cmp(*self, *other) + } +} + +// The ordering here doesn't really matter as it is only used to sort for display purposes +fn n_weekday_cmp(val1: NWeekday, val2: NWeekday) -> Ordering { + match val1 { + NWeekday::Every(wday) => match val2 { + NWeekday::Every(other_wday) => wday + .num_days_from_monday() + .cmp(&other_wday.num_days_from_monday()), + NWeekday::Nth(_n, _other_wday) => Ordering::Less, + }, + NWeekday::Nth(n, wday) => match val2 { + NWeekday::Every(_) => Ordering::Greater, + NWeekday::Nth(other_n, other_wday) => match n.cmp(&other_n) { + Ordering::Equal => wday + .num_days_from_monday() + .cmp(&other_wday.num_days_from_monday()), + less_or_greater => less_or_greater, + }, + }, + } +} + +impl NWeekday { + pub fn new(number: Option, weekday: Weekday) -> Self { + match number { + Some(number) => Self::Nth(number, weekday), + None => Self::Every(weekday), + } + } +} diff --git a/vendor/calcard/src/datecalc/rrule_iter.rs b/vendor/calcard/src/datecalc/rrule_iter.rs new file mode 100644 index 00000000..fef97c31 --- /dev/null +++ b/vendor/calcard/src/datecalc/rrule_iter.rs @@ -0,0 +1,266 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +use super::counter_date::DateTimeIter; +use super::rrule::RRule; +use super::utils::add_time_to_date; +use super::{IterInfo, MAX_ITER_LOOP, build_pos_list, utils::date_from_ordinal}; +use super::{get_hour, get_minute, get_second}; +use crate::common::timezone::Tz; +use crate::icalendar::ICalendarFrequency; +use chrono::NaiveTime; +use std::collections::VecDeque; + +// inbuxa: passes plus skipped times, counted for the expansion budget tests +#[cfg(test)] +thread_local! { + pub(crate) static ITERATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[derive(Debug, Clone)] +pub(crate) struct RRuleIter { + pub(crate) counter_date: DateTimeIter, + pub(crate) ii: IterInfo, + pub(crate) timeset: Vec, + pub(crate) dt_start: chrono::DateTime, + pub(crate) buffer: VecDeque>, + pub(crate) finished: bool, + pub(crate) count: Option, + pub(crate) limited: bool, + pub(crate) was_limited: bool, + // inbuxa: work left from the expansion budget, see `MAX_EXPANSION_WORK` + pub(crate) budget: u32, + pub(crate) budget_exhausted: bool, +} + +impl RRuleIter { + pub(crate) fn new(rrule: &RRule, dt_start: &chrono::DateTime, limited: bool) -> Self { + let ii = IterInfo::new(rrule, dt_start); + + let hour = get_hour(dt_start); + let minute = get_minute(dt_start); + let second = get_second(dt_start); + let timeset = ii.get_timeset(hour, minute, second); + let count = ii.rrule().count; + + Self { + counter_date: dt_start.into(), + ii, + timeset, + dt_start: *dt_start, + buffer: VecDeque::new(), + finished: false, + count, + limited, + was_limited: false, + budget: u32::MAX, + budget_exhausted: false, + } + } + + /// inbuxa: limits the work this iterator may spend to `budget` units; once + /// it runs out, the iterator ends and `budget_exhausted` is set. + pub(crate) fn with_budget(mut self, budget: u32) -> Self { + self.budget = budget; + self + } + + /// Attempts to add a date to the result. Returns `true` if we should + /// terminate the iteration. + fn try_add_datetime( + dt: chrono::DateTime, + rrule: &RRule, + count: &mut Option, + buffer: &mut VecDeque>, + dt_start: &chrono::DateTime, + ) -> bool { + if matches!(rrule.until, Some(until) if dt > until) { + // We can break because `pos_list` is sorted and + // all the next dates will only be larger than `until`. + return true; + } + + if dt >= *dt_start { + buffer.push_back(dt); + + if let Some(count) = count { + *count -= 1; + + if *count == 0 { + return true; + } + } + } + false + } + + /// Generates a list of dates that will be added to the buffer. + /// Returns true if finished, no more items should/can be returned. + fn generate(&mut self) -> bool { + // Do early check if done (if known) + if self.finished { + return true; + } + // Check if the count is set, and if 0 + if matches!(self.count, Some(count) if count == 0) { + return true; + } + + let rrule = self.ii.rrule(); + + if rrule.interval == 0 { + return true; + } + + let mut loop_counter: u32 = 0; + // Loop until there is at least 1 item in the buffer. + while self.buffer.is_empty() { + // Prevent infinite loops + if self.limited { + loop_counter += 1; + if loop_counter >= MAX_ITER_LOOP { + self.finished = true; + self.was_limited = true; + return true; + } + } + let rrule = self.ii.rrule(); + + let dayset = self.ii.get_dayset( + rrule.freq, + self.counter_date.year, + self.counter_date.month, + self.counter_date.day, + ); + + let tz = self.dt_start.timezone(); + + if rrule.by_set_pos.is_empty() { + // Loop over `start..end` + for current_day in &dayset { + let current_day = *current_day as i64; + let year_ordinal = self.ii.year_ordinal(); + // Ordinal conversion uses UTC: if we apply local-TZ here, then + // just below we'll end up double-applying. + let date = date_from_ordinal(year_ordinal + current_day); + for time in &self.timeset { + let Some(dt) = add_time_to_date(tz, date, *time) else { + continue; + }; + if Self::try_add_datetime( + dt, + rrule, + &mut self.count, + &mut self.buffer, + &self.dt_start, + ) { + return true; + } + } + } + } else { + let pos_list = build_pos_list( + &rrule.by_set_pos, + &dayset, + &self.timeset, + self.ii.year_ordinal(), + self.dt_start.timezone(), + ); + for dt in pos_list { + if Self::try_add_datetime( + dt, + rrule, + &mut self.count, + &mut self.buffer, + &self.dt_start, + ) { + return true; + } + } + } + + // inbuxa: what this pass cost; the buffer was empty when it began + let produced = self.buffer.len(); + let candidates = dayset.len().saturating_mul(self.timeset.len()); + let mut work = if produced == 0 { + let days = match rrule.freq { + ICalendarFrequency::Yearly => usize::from(self.ii.year_len()), + ICalendarFrequency::Monthly => 31, + ICalendarFrequency::Weekly => 7, + _ => 1, + }; + // one unit per 16 days scanned keeps a unit near the cost of a + // daily pass + days.div_ceil(16).saturating_add(candidates) + } else { + 1 + candidates.saturating_sub(produced) + }; + + let increment_day = dayset.is_empty(); + let mut steps = 0; + let incremented = self + .counter_date + .increment_counted(rrule, increment_day, &mut steps); + work = work.saturating_add(steps as usize); + #[cfg(test)] + ITERATIONS.with(|count| count.set(count.get() + 1 + u64::from(steps))); + let Some(budget) = self + .budget + .checked_sub(u32::try_from(work).unwrap_or(u32::MAX)) + else { + self.budget = 0; + self.budget_exhausted = true; + self.finished = true; + return true; + }; + self.budget = budget; + if incremented.is_err() { + self.finished = true; + return true; + } + + if matches!( + rrule.freq, + ICalendarFrequency::Hourly + | ICalendarFrequency::Minutely + | ICalendarFrequency::Secondly + ) { + let hour = self.counter_date.hour as u8; + let minute = self.counter_date.minute as u8; + let second = self.counter_date.second as u8; + self.timeset = self.ii.get_timeset_unchecked(hour, minute, second); + } + + self.ii.rebuild(&self.counter_date); + } + + // Indicate that there might be more items on the next iteration. + false + } +} + +impl Iterator for RRuleIter { + type Item = chrono::DateTime; + + fn next(&mut self) -> Option { + if !self.buffer.is_empty() { + return self.buffer.pop_front(); + } + + if self.finished { + return None; + } + + self.finished = self.generate(); + + if self.buffer.is_empty() { + self.finished = true; + } + self.buffer.pop_front() + } +} diff --git a/vendor/calcard/src/datecalc/utils.rs b/vendor/calcard/src/datecalc/utils.rs new file mode 100644 index 00000000..e9c5b07f --- /dev/null +++ b/vendor/calcard/src/datecalc/utils.rs @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::duration_from_midnight; +use crate::common::timezone::Tz; +use chrono::{NaiveDate, NaiveTime, Utc}; +use std::ops; + +const DAY_SECS: i64 = 24 * 60 * 60; + +#[inline(always)] +pub(crate) fn date_from_ordinal(ordinal: i64) -> NaiveDate { + chrono::DateTime::::from_timestamp(ordinal * DAY_SECS, 0) + .unwrap_or_default() + .date_naive() +} + +#[inline(always)] +pub(crate) fn days_since_unix_epoch(date: &chrono::DateTime) -> i64 { + date.timestamp() / DAY_SECS +} + +#[inline(always)] +pub(crate) fn is_leap_year(year: i32) -> bool { + year.trailing_zeros() >= 2 && (year % 25 != 0 || year.trailing_zeros() >= 4) +} + +#[inline(always)] +pub(crate) fn get_year_len(year: i32) -> u16 { + if is_leap_year(year) { 366 } else { 365 } +} + +pub(crate) trait DifferentSigns { + fn different_sign(a: Self, b: Self) -> bool; +} + +macro_rules! impl_different_signs { + ($($ty:ty),*) => { + $( + impl DifferentSigns for $ty { + fn different_sign(a: Self, b: Self) -> bool { + a > 0 && b < 0 || a < 0 && b > 0 + } + })* + }; + (@unsigned, $($ty:ty),*) => { + $( + impl DifferentSigns for $ty { + fn different_sign(_: Self, _: Self) -> bool { + false + } + })* + }; +} +impl_different_signs!(isize, i32, i16); +impl_different_signs!(@unsigned, usize, u32, u16, u8); + +pub(crate) fn pymod(a: T, b: T) -> T +where + T: DifferentSigns + Copy + ops::Rem + ops::Add, +{ + let r = a % b; + // If r and b differ in sign, add b to wrap the result to the correct sign. + if T::different_sign(r, b) { r + b } else { r } +} + +pub(crate) fn add_time_to_date( + tz: Tz, + date: NaiveDate, + time: NaiveTime, +) -> Option> { + if let Some(dt) = date.and_time(time).and_local_timezone(tz).single() { + return Some(dt); + } + // If the day is a daylight saving time, the above code might not work, and we + // can try to get a valid datetime by adding the `time` as a duration instead. + let dt = date.and_hms_opt(0, 0, 0)?.and_local_timezone(tz).single()?; + let day_duration = duration_from_midnight(time); + dt.checked_add_signed(day_duration) +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::{Duration, TimeZone}; + + #[test] + fn naive_date_from_ordinal() { + let tests = [ + (-1, NaiveDate::from_ymd_opt(1969, 12, 31).unwrap()), + (0, NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()), + (1, NaiveDate::from_ymd_opt(1970, 1, 2).unwrap()), + (10, NaiveDate::from_ymd_opt(1970, 1, 11).unwrap()), + (365, NaiveDate::from_ymd_opt(1971, 1, 1).unwrap()), + (19877, NaiveDate::from_ymd_opt(2024, 6, 3).unwrap()), + ]; + + for (days, expected) in tests { + assert_eq!(date_from_ordinal(days), expected, "seconds: {}", days); + } + } + + #[test] + fn python_mod() { + assert_eq!(pymod(2, -3), -1); + assert_eq!(pymod(-2, 3), 1); + assert_eq!(pymod(-2, -3), -2); + assert_eq!(pymod(-3, -3), 0); + assert_eq!(pymod(0, 3), 0); + assert_eq!(pymod(1, 3), 1); + assert_eq!(pymod(2, 3), 2); + assert_eq!(pymod(3, 3), 0); + assert_eq!(pymod(4, 3), 1); + assert_eq!(pymod(6, 3), 0); + assert_eq!(pymod(-6, 3), 0); + assert_eq!(pymod(-6, -3), 0); + assert_eq!(pymod(6, -3), 0); + } + + #[test] + fn leap_year() { + let tests = [ + (2015, false), + (2016, true), + (2017, false), + (2018, false), + (2019, false), + (2020, true), + (2021, false), + ]; + + for (year, expected_output) in tests { + let res = is_leap_year(year); + assert_eq!(res, expected_output); + } + } + + #[test] + fn year_length() { + let tests = [(2015, 365), (2016, 366)]; + + for (year, expected_output) in tests { + let res = get_year_len(year); + assert_eq!(res, expected_output); + } + } + + #[test] + fn adds_time_to_date() { + let tests = [ + ( + Tz::Tz(chrono_tz::Tz::UTC), + NaiveDate::from_ymd_opt(2017, 1, 1).unwrap(), + NaiveTime::from_hms_opt(1, 15, 30).unwrap(), + Some( + Tz::Tz(chrono_tz::Tz::UTC) + .with_ymd_and_hms(2017, 1, 1, 1, 15, 30) + .unwrap(), + ), + ), + ( + Tz::Tz(chrono_tz::Tz::America__Vancouver), + NaiveDate::from_ymd_opt(2021, 3, 14).unwrap(), + NaiveTime::from_hms_opt(2, 22, 10).unwrap(), + Some( + Tz::Tz(chrono_tz::Tz::America__Vancouver) + .with_ymd_and_hms(2021, 3, 14, 0, 0, 0) + .unwrap() + + Duration::hours(2) + + Duration::minutes(22) + + Duration::seconds(10), + ), + ), + ( + Tz::Tz(chrono_tz::Tz::America__New_York), + NaiveDate::from_ymd_opt(1997, 10, 26).unwrap(), + NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + Some( + Tz::Tz(chrono_tz::Tz::America__New_York) + .with_ymd_and_hms(1997, 10, 26, 9, 0, 0) + .unwrap(), + ), + ), + ]; + + for (tz, date, time, expected_output) in tests { + let res = add_time_to_date(tz, date, time); + assert_eq!(res, expected_output); + } + } +} diff --git a/vendor/calcard/src/datecalc/validate.rs b/vendor/calcard/src/datecalc/validate.rs new file mode 100644 index 00000000..45c28bcf --- /dev/null +++ b/vendor/calcard/src/datecalc/validate.rs @@ -0,0 +1,647 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + error::ValidationError, + rrule::{NWeekday, RRule}, +}; +use crate::{common::timezone::Tz, icalendar::ICalendarFrequency}; +use std::{borrow::Cow, ops::RangeInclusive}; + +pub(crate) static MONTH_RANGE: RangeInclusive = 1..=12; +pub(crate) static YEAR_RANGE: RangeInclusive = -10_000..=10_000; + +type Validator = &'static dyn Fn(&RRule, &chrono::DateTime) -> Result<(), ValidationError>; + +const VALIDATION_PIPELINE: [Validator; 11] = [ + &validate_until, + &validate_by_set_pos, + &validate_by_month, + &validate_by_month_day, + &validate_by_year_day, + &validate_by_week_number, + &validate_by_weekday, + &validate_by_hour, + &validate_by_minute, + &validate_by_second, + &validate_by_easter, +]; + +/// Check if rules are valid as defined by the RFC and crate limitations. +/// It checks all values in the [`RRule`] and makes sure that they are in +/// the accepted ranges. If the function returns `Ok`, no errors where found. +/// +/// This check should always be done and just enforces limits set by the standard. +/// Validation will always be enforced and can not be disabled using feature flags. +pub(crate) fn validate_rrule_forced( + rrule: &RRule, + dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + VALIDATION_PIPELINE + .into_iter() + .try_for_each(|validator| validator(rrule, dt_start)) +} + +// Until: +// - Timezones are correctly synced as specified in the RFC +// - Value should be later than `dt_start`. +fn validate_until(rrule: &RRule, dt_start: &chrono::DateTime) -> Result<(), ValidationError> { + match rrule.until { + Some(until) => { + match dt_start.timezone() { + Tz::Tz(_) => { + if until.timezone() != Tz::UTC { + return Err(ValidationError::DtStartUntilMismatchTimezone { + dt_start_tz: dt_start + .timezone() + .name() + .unwrap_or(Cow::Borrowed("Floating")), + until_tz: until.timezone().name().unwrap_or(Cow::Borrowed("Floating")), + expected: vec!["UTC".into()], + }); + } + } + v => { + let allowed_timezones = vec![v, Tz::UTC]; + if !allowed_timezones.contains(&until.timezone()) { + return Err(ValidationError::DtStartUntilMismatchTimezone { + dt_start_tz: dt_start + .timezone() + .name() + .unwrap_or(Cow::Borrowed("Floating")), + until_tz: until.timezone().name().unwrap_or(Cow::Borrowed("Floating")), + expected: allowed_timezones + .into_iter() + .map(|tz| tz.name().unwrap_or(Cow::Borrowed("Floating"))) + .collect(), + }); + } + } + } + + if until < *dt_start { + return Err(ValidationError::UntilBeforeStart { + until: until.to_rfc3339(), + dt_start: dt_start.to_rfc3339(), + }); + } + Ok(()) + } + _ => Ok(()), + } +} + +// By_set_pos: +// - Can be a value from -366 to -1 and 1 to 366 depending on `freq` +fn validate_by_set_pos( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_not_equal_for_vec(&0, &rrule.by_set_pos, "BYSETPOS")?; + let range = match rrule.freq { + ICalendarFrequency::Yearly | ICalendarFrequency::Daily => -366..=366, // TODO is the daily range correct? + ICalendarFrequency::Monthly => -31..=31, + ICalendarFrequency::Weekly => -53..=53, + ICalendarFrequency::Hourly => -24..=24, + ICalendarFrequency::Minutely | ICalendarFrequency::Secondly => -60..=60, + }; + if let Err(value) = validate_range_for_vec_error(&range, &rrule.by_set_pos) { + return Err(ValidationError::InvalidFieldValueRangeWithFreq { + field: "BYSETPOS".into(), + value: value.to_string(), + freq: rrule.freq, + start_idx: range.start().to_string(), + end_idx: range.end().to_string(), + }); + } + // - It MUST only be used in conjunction with another BYxxx rule part. + if !rrule.by_set_pos.is_empty() + && rrule.by_easter.is_none() + && rrule.by_hour.is_empty() + && rrule.by_minute.is_empty() + && rrule.by_second.is_empty() + && rrule.by_month_day.is_empty() + && rrule.by_month.is_empty() + && rrule.by_year_day.is_empty() + && rrule.by_week_no.is_empty() + && rrule.by_weekday.is_empty() + { + return Err(ValidationError::BySetPosWithoutByRule); + } + Ok(()) +} + +// By_month: +// - Can be a value from 1 to 12. +fn validate_by_month( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + for item in &rrule.by_month { + if !MONTH_RANGE.contains(&item.month()) { + return Err(ValidationError::InvalidFieldValueRange { + field: "BYMONTH".to_string(), + value: item.to_string(), + start_idx: 1.to_string(), + end_idx: 12.to_string(), + }); + } + } + Ok(()) +} + +// By_month_day: +// - Can be a value from -31 to -1 and 1 to 31. +fn validate_by_month_day( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_not_equal_for_vec(&0, &rrule.by_month_day, "BYMONTHDAY")?; + validate_range_for_vec(&(-31..=31), &rrule.by_month_day, "BYMONTHDAY")?; + // - MUST NOT be specified when the FREQ rule part is set to WEEKLY. + // Validated below + if !rrule.by_month_day.is_empty() { + let valid = rrule.freq != ICalendarFrequency::Weekly; + if !valid { + return Err(ValidationError::InvalidByRuleAndFrequency { + by_rule: "BYMONTHDAY".into(), + freq: rrule.freq, + }); + } + } + Ok(()) +} + +// By_year_day: +// - Can be a value from -366 to -1 and 1 to 366. +fn validate_by_year_day( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_not_equal_for_vec(&0, &rrule.by_year_day, "BYYEARDAY")?; + validate_range_for_vec(&(-366..=366), &rrule.by_year_day, "BYYEARDAY")?; + // - MUST NOT be specified when the FREQ rule part is set to DAILY, WEEKLY, or MONTHLY. + // Validated below + if !rrule.by_year_day.is_empty() { + let valid = !matches!( + rrule.freq, + ICalendarFrequency::Monthly | ICalendarFrequency::Weekly | ICalendarFrequency::Daily + ); + if !valid { + return Err(ValidationError::InvalidByRuleAndFrequency { + by_rule: "BYYEARDAY".into(), + freq: rrule.freq, + }); + } + } + Ok(()) +} +// By_week_no: +// - Can be a value from -53 to -1 and 1 to 53. +fn validate_by_week_number( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_not_equal_for_vec(&0, &rrule.by_week_no, "BYWEEKNO")?; + validate_range_for_vec(&(-53..=53), &rrule.by_week_no, "BYWEEKNO")?; + // - MUST NOT be used when the FREQ rule part is set to anything other than YEARLY. + // Validated below + if !rrule.by_week_no.is_empty() { + let valid = rrule.freq == ICalendarFrequency::Yearly; + if !valid { + return Err(ValidationError::InvalidByRuleAndFrequency { + by_rule: "BYWEEKNO".into(), + freq: rrule.freq, + }); + } + } + Ok(()) +} + +// By_weekday: +// - Check if value for `Nth` is within range. +// The Range depends on frequency and can only happen weekly, so `/7` from normal count. +fn validate_by_weekday( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + let range = match rrule.freq { + ICalendarFrequency::Yearly | ICalendarFrequency::Daily => (-366 / 7)..=(366 / 7 + 1), // TODO is the daily range correct? + ICalendarFrequency::Monthly => (-31 / 7)..=(31 / 7 + 1), + ICalendarFrequency::Weekly => (-53 / 7)..=(53 / 7 + 1), + ICalendarFrequency::Hourly => (-24 / 7)..=(24 / 7 + 1), + ICalendarFrequency::Minutely | ICalendarFrequency::Secondly => (-60 / 7)..=(60 / 7 + 1), + }; + for item in &rrule.by_weekday { + if let NWeekday::Nth(number, _weekday) = item { + // If value not in range = error + if !range.contains(number) { + return Err(ValidationError::InvalidFieldValueRangeWithFreq { + field: "BYDAY".into(), + value: number.to_string(), + freq: rrule.freq, + start_idx: range.start().to_string(), + end_idx: range.end().to_string(), + }); + } + } + } + Ok(()) +} + +// By_hour: +// - Can be a value from 0 to 23. +fn validate_by_hour( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_range_for_vec(&(0..=23), &rrule.by_hour, "BYHOUR") +} + +// By_minute: +// - Can be a value from 0 to 59. +fn validate_by_minute( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_range_for_vec(&(0..=59), &rrule.by_minute, "BYMINUTE") +} + +// By_second: +// - Can be a value from 0 to 59. +fn validate_by_second( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + validate_range_for_vec(&(0..=59), &rrule.by_second, "BYSECOND") +} + +fn validate_by_easter( + rrule: &RRule, + _dt_start: &chrono::DateTime, +) -> Result<(), ValidationError> { + // By_easter: + // - Can be a value from -366 to 366. + // Validated below + if let Some(by_easter) = &rrule.by_easter { + validate_range_for_vec(&(-366..=366), &[*by_easter], "BYEASTER")?; + } + // - Can only be used on frequency: Yearly, Monthly, Daily + // Validated below + if rrule.by_easter.is_some() { + let valid = matches!( + rrule.freq, + ICalendarFrequency::Yearly | ICalendarFrequency::Monthly | ICalendarFrequency::Daily + ); + if !valid { + return Err(ValidationError::InvalidByRuleAndFrequency { + by_rule: "BYEASTER".into(), + freq: rrule.freq, + }); + } + } + // - Can only be used when `by_hour`, `by_minute` and `by_second` are used. + // TODO don't know why this is true, but seems to loop forever otherwise. + if rrule.by_easter.is_some() + && (rrule.by_hour.is_empty() || rrule.by_minute.is_empty() || rrule.by_second.is_empty()) + { + return Err(ValidationError::InvalidByRuleWithByEaster); + } + + Ok(()) +} + +fn validate_range_for_vec_error<'a, T: PartialOrd>( + range: &RangeInclusive, + list: &'a [T], +) -> Result<(), &'a T> { + for item in list { + // If value not in range = error + if !range.contains(item) { + return Err(item); + } + } + Ok(()) +} + +fn validate_range_for_vec( + range: &RangeInclusive, + list: &[T], + field: &str, +) -> Result<(), ValidationError> { + for item in list { + // If value not in range = error + if !range.contains(item) { + return Err(ValidationError::InvalidFieldValueRange { + field: field.to_string(), + value: item.to_string(), + start_idx: range.start().to_string(), + end_idx: range.end().to_string(), + }); + } + } + Ok(()) +} + +fn validate_not_equal_for_vec + ToString>( + value: &T, + list: &[T], + field: &str, +) -> Result<(), ValidationError> { + if list.iter().any(|item| item == value) { + return Err(ValidationError::InvalidFieldValue { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::icalendar::ICalendarFrequency; + use chrono::TimeZone; + + const UTC: Tz = Tz::UTC; + + #[test] + fn rejects_by_set_pos_without_byxxx_rule() { + let rrule = RRule { + by_set_pos: vec![-1], + ..Default::default() + }; + let dt_start = UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); + let res = validate_rrule_forced(&rrule, &dt_start); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!(err, ValidationError::BySetPosWithoutByRule); + + // When creating RRule directly, + // if `rrule.by_set_hour` is empty, then it is going to default to + // `dt_start.hour()`, therefore, there is always a BYXXX + // rule, and the rrule is accepted. + let res = rrule.validate(dt_start); + assert!(res.is_ok()); + } + + #[test] + fn rejects_by_rule_field_with_invalid_value() { + let tests = [ + ( + "BYSETPOS", + RRule { + by_set_pos: vec![0], + ..Default::default() + }, + ), + ( + "BYSETPOS", + RRule { + by_set_pos: vec![1, -2, 0], + ..Default::default() + }, + ), + ( + "BYMONTHDAY", + RRule { + by_month_day: vec![0], + ..Default::default() + }, + ), + ( + "BYYEARDAY", + RRule { + by_year_day: vec![0], + ..Default::default() + }, + ), + ]; + for (field, rrule) in tests { + let res = + validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!( + err, + ValidationError::InvalidFieldValue { + field: field.into(), + value: "0".into() + } + ); + } + } + + #[test] + fn rejects_by_rule_field_with_value_outside_allowed_range() { + let tests = [ + ( + "BYMONTHDAY", + RRule { + by_month_day: vec![34], + ..Default::default() + }, + "34", + "-31", + "31", + ), + ( + "BYYEARDAY", + RRule { + by_year_day: vec![17, 400], + ..Default::default() + }, + "400", + "-366", + "366", + ), + ]; + for (field, rrule, value, start_idx, end_idx) in tests { + let res = + validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!( + err, + ValidationError::InvalidFieldValueRange { + field: field.into(), + value: value.into(), + start_idx: start_idx.into(), + end_idx: end_idx.into() + } + ); + } + } + + #[test] + fn rejects_by_rule_value_outside_allowed_freq_range() { + let tests = [ + ( + "BYSETPOS", + RRule { + freq: ICalendarFrequency::Hourly, + by_set_pos: vec![30], + ..Default::default() + }, + "30", + "-24", + "24", + ), + ( + "BYSETPOS", + RRule { + freq: ICalendarFrequency::Yearly, + by_set_pos: vec![400], + ..Default::default() + }, + "400", + "-366", + "366", + ), + ]; + for (field, rrule, value, start_idx, end_idx) in tests { + let res = + validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!( + err, + ValidationError::InvalidFieldValueRangeWithFreq { + field: field.into(), + freq: rrule.freq, + value: value.into(), + start_idx: start_idx.into(), + end_idx: end_idx.into(), + } + ); + } + } + + #[test] + fn rejects_invalid_by_rule_and_freq_combos() { + let tests = [ + ( + "BYMONTHDAY", + RRule { + freq: ICalendarFrequency::Weekly, + by_month_day: vec![-1], + ..Default::default() + }, + ), + ( + "BYYEARDAY", + RRule { + freq: ICalendarFrequency::Monthly, + by_year_day: vec![120], + ..Default::default() + }, + ), + ( + "BYYEARDAY", + RRule { + freq: ICalendarFrequency::Weekly, + by_year_day: vec![120], + ..Default::default() + }, + ), + ( + "BYYEARDAY", + RRule { + freq: ICalendarFrequency::Daily, + by_year_day: vec![120], + ..Default::default() + }, + ), + ]; + for (field, rrule) in tests { + let res = + validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!( + err, + ValidationError::InvalidByRuleAndFrequency { + by_rule: field.into(), + freq: rrule.freq + } + ); + } + } + + #[test] + fn rejects_start_date_after_until() { + let rrule = RRule { + until: Some(UTC.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()), + ..Default::default() + }; + let dt_start = UTC.with_ymd_and_hms(2020, 1, 2, 0, 0, 0).unwrap(); + let res = validate_rrule_forced(&rrule, &dt_start); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert_eq!( + err, + ValidationError::UntilBeforeStart { + until: rrule.until.unwrap().to_rfc3339(), + dt_start: dt_start.to_rfc3339() + } + ); + } + + #[test] + fn allows_until_with_compatible_timezone() { + fn t(start_tz: Tz, until_tz: Tz) -> (chrono::DateTime, chrono::DateTime) { + ( + start_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), + until_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), + ) + } + + let tests = [ + t(Tz::Floating, Tz::Floating), + t(Tz::Floating, UTC), + t(UTC, UTC), + ]; + + for (start_date, until) in tests { + let rrule = RRule { + until: Some(until), + ..Default::default() + }; + let res = validate_rrule_forced(&rrule, &start_date); + assert!(res.is_ok()); + } + } + + #[test] + fn rejects_until_with_incompatible_timezone() { + fn t(start_tz: Tz, until_tz: Tz) -> (chrono::DateTime, chrono::DateTime) { + ( + start_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), + until_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), + ) + } + + let tests = [ + t(Tz::Tz(chrono_tz::Tz::UTC), Tz::Floating), + t(Tz::Tz(chrono_tz::Europe::Berlin), Tz::Floating), + t(Tz::Floating, Tz::Tz(chrono_tz::Europe::Berlin)), + ]; + + for (start_date, until) in tests { + let rrule = RRule { + until: Some(until), + ..Default::default() + }; + let res = validate_rrule_forced(&rrule, &start_date); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert!(matches!( + err, + ValidationError::DtStartUntilMismatchTimezone { .. } + )); + } + } +} diff --git a/vendor/calcard/src/datecalc/yearinfo.rs b/vendor/calcard/src/datecalc/yearinfo.rs new file mode 100644 index 00000000..487f6644 --- /dev/null +++ b/vendor/calcard/src/datecalc/yearinfo.rs @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + masks::MASKS, + rrule::RRule, + utils::{days_since_unix_epoch, get_year_len, pymod}, +}; +use chrono::{Datelike, TimeZone, Utc}; + +#[derive(Debug)] +pub(crate) struct BaseMasks { + month_mask: &'static [u8], + month_day_mask: &'static [i8], + neg_month_day_mask: &'static [i8], + month_range: &'static [u16], + weekday_mask: &'static [u32], +} + +fn base_year_masks(year_weekday: u16, year_len: u16) -> BaseMasks { + if year_len == 365 { + BaseMasks { + month_mask: &MASKS.month_365, + month_day_mask: &MASKS.month_day_365, + neg_month_day_mask: &MASKS.neg_month_day_365, + month_range: &MASKS.month_365_range, + weekday_mask: &MASKS.weekday[usize::from(year_weekday)..], + } + } else { + BaseMasks { + month_mask: &MASKS.month_366, + month_day_mask: &MASKS.month_day_366, + neg_month_day_mask: &MASKS.neg_month_day_366, + month_range: &MASKS.month_366_range, + weekday_mask: &MASKS.weekday[usize::from(year_weekday)..], + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct YearInfo { + /// The year + pub year: i32, + /// Number of days in the current year (365 or 366) + pub year_len: u16, + /// Number of days in the next year (365 or 366) + pub next_year_len: u16, + /// Number of days since Unix epoch + pub year_ordinal: i64, + pub month_mask: &'static [u8], + pub month_day_mask: &'static [i8], + pub neg_month_day_mask: &'static [i8], + pub month_range: &'static [u16], + pub weekday_mask: &'static [u32], + /// Week number mask + pub week_no_mask: Option>, +} + +impl YearInfo { + pub fn new(year: i32, rrule: &RRule) -> Self { + // It should never fail, since there is always a 1st of January, is there? + let first_year_day = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap(); + + let year_len = get_year_len(year); + let next_year_len = get_year_len(year + 1); + let year_ordinal = days_since_unix_epoch(&first_year_day); + let year_start_weekday = first_year_day.weekday().num_days_from_monday() as u16; + let base_masks = base_year_masks(year_start_weekday, year_len); + + let mut result = Self { + year, + year_len, + next_year_len, + year_ordinal, + week_no_mask: None, + month_mask: base_masks.month_mask, + month_day_mask: base_masks.month_day_mask, + neg_month_day_mask: base_masks.neg_month_day_mask, + month_range: base_masks.month_range, + weekday_mask: base_masks.weekday_mask, + }; + + if rrule.by_week_no.is_empty() { + return result; + } + + let mut week_no_mask = vec![0; usize::from(year_len) + 7]; + + let rrule_week_start = rrule.week_start.num_days_from_monday() as u16; + let mut no1_week_start = pymod(7 - year_start_weekday + rrule_week_start, 7); + let first_week_start = no1_week_start; + let year_len_ext = if no1_week_start >= 4 { + no1_week_start = 0; + // Number of days in the year, plus the days we got + // from last year. + let diff = i32::from(year_start_weekday) - i32::from(rrule_week_start); + result.year_len + pymod(diff, 7) as u16 + } else { + // Number of days in the year, minus the days we + // left in last year. + year_len - no1_week_start + }; + + let div = year_len_ext / 7; + let year_mod = pymod(year_len_ext, 7); + let num_weeks = div + (year_mod / 4); + + for &(mut n) in &rrule.by_week_no { + let num_weeks = num_weeks as i8; + if n < 0 { + n += num_weeks + 1; + } + if !(n > 0 && n <= num_weeks) { + continue; + } + + let i = if n > 1 { + let n = n as u16; + let mut i = no1_week_start + ((n - 1) * 7); + if no1_week_start != first_week_start { + i -= 7 - first_week_start; + } + i + } else { + no1_week_start + }; + + for j in i..i + 7 { + let j = usize::from(j); + week_no_mask[j] = 1; + if result.weekday_mask[j + 1] == rrule.week_start.num_days_from_monday() { + break; + } + } + } + + if rrule.by_week_no.contains(&1) { + // Check week number 1 of next years as well + let mut i = no1_week_start + num_weeks * 7; + if no1_week_start != first_week_start { + i -= 7 - first_week_start; + } + if i < year_len { + // If week starts in next year, we + // don't care about it. + for j in i..i + 7 { + let j = usize::from(j); + week_no_mask[j] = 1; + if result.weekday_mask[j + 1] == rrule.week_start.num_days_from_monday() { + break; + } + } + } + } + + if no1_week_start > 0 { + // Check last week number of last year as + // well. If no1_week_start is 0, either the year + // started on week start, or week number 1 + // got days from last year, so there are no + // days from last year's last week number in + // this year. + let l_num_weeks = if rrule.by_week_no.contains(&-1) { + -1 + } else { + let l_year_weekday = Utc + .with_ymd_and_hms(year - 1, 1, 1, 0, 0, 0) + // It should never fail, since there is always a 1st of January, is there? + .unwrap() + .weekday() + .num_days_from_monday() as u16; + + let rrule_week_start = rrule.week_start.num_days_from_monday() as u16; + let ln_no1_week_start = pymod(7 - l_year_weekday + rrule_week_start, 7); + + let l_year_len = get_year_len(year - 1); + let week_start = if ln_no1_week_start >= 4 { + l_year_len + + pymod(i32::from(l_year_weekday) - i32::from(rrule_week_start), 7) as u16 + } else { + year_len - no1_week_start + }; + + (52 + pymod(week_start, 7) / 4) as i8 + }; + + if rrule.by_week_no.contains(&l_num_weeks) { + for i in 0..no1_week_start { + let i = usize::from(i); + week_no_mask[i] = 1; + } + } + } + + result.week_no_mask = Some(week_no_mask); + + result + } +} diff --git a/vendor/calcard/src/icalendar/builder.rs b/vendor/calcard/src/icalendar/builder.rs new file mode 100644 index 00000000..c6db9e3a --- /dev/null +++ b/vendor/calcard/src/icalendar/builder.rs @@ -0,0 +1,808 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{ + IanaType, PartialDateTime, + parser::{Boolean, Integer}, + }, + icalendar::*, +}; +use ahash::{AHashMap, AHashSet}; + +impl ICalendar { + pub fn remove_component_ids(&mut self, component_ids: &[u32]) { + // Validate component IDs + let max_component_id = self.components.len() as u32; + let mut remove_component_ids = AHashSet::from_iter( + component_ids + .iter() + .filter(|id| **id < max_component_id) + .cloned(), + ); + + // Add sub-components to the set + for (component_id, component) in self.components.iter().enumerate() { + if remove_component_ids.contains(&(component_id as u32)) { + remove_component_ids.extend(&component.component_ids); + } + } + + if !remove_component_ids.is_empty() { + let id_mappings = (0..max_component_id) + .filter(|i| !remove_component_ids.contains(i)) + .enumerate() + .map(|(new_id, old_id)| (old_id, new_id as u32)) + .collect::>(); + + for (component_id, mut component) in + std::mem::replace(&mut self.components, Vec::with_capacity(id_mappings.len())) + .into_iter() + .enumerate() + { + if !remove_component_ids.contains(&(component_id as u32)) { + let component_ids = component + .component_ids + .iter() + .filter_map(|id| id_mappings.get(id).cloned()) + .collect(); + component.component_ids = component_ids; + self.components.push(component); + } + } + } + } + + pub fn copy_timezones(&mut self, other: &ICalendar) { + for component in &other.components { + if component.component_type == ICalendarComponentType::VTimezone { + let tz_component_id = self.components.len(); + self.components[0] + .component_ids + .insert(1, tz_component_id as u32); + self.components.push(ICalendarComponent { + component_type: ICalendarComponentType::VTimezone, + entries: component.entries.clone(), + component_ids: vec![], + }); + for component_id in &component.component_ids { + let item_id = self.components.len() as u32; + let item = &other.components[*component_id as usize]; + self.components.push(ICalendarComponent { + component_type: item.component_type.clone(), + entries: item.entries.clone(), + component_ids: vec![], + }); + self.components[tz_component_id].component_ids.push(item_id); + } + } + } + } +} + +impl ICalendarComponent { + pub fn new(component_type: ICalendarComponentType) -> Self { + Self { + component_type, + entries: Vec::new(), + component_ids: Vec::new(), + } + } + + pub fn add_dtstamp(&mut self, dt_stamp: PartialDateTime) { + self.entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstamp, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new(dt_stamp))], + }); + } + + pub fn add_sequence(&mut self, sequence: i64) { + self.entries.push(ICalendarEntry { + name: ICalendarProperty::Sequence, + params: vec![], + values: vec![ICalendarValue::Integer(sequence)], + }); + } + + pub fn add_uid(&mut self, uid: &str) { + self.entries.push(ICalendarEntry { + name: ICalendarProperty::Uid, + params: vec![], + values: vec![ICalendarValue::Text(uid.to_string())], + }); + } + + pub fn add_property(&mut self, name: ICalendarProperty, value: impl Into) { + self.entries.push(ICalendarEntry { + name, + params: vec![], + values: vec![value.into()], + }); + } + + pub fn add_property_with_params( + &mut self, + name: ICalendarProperty, + params: impl IntoIterator, + value: impl Into, + ) { + self.entries.push(ICalendarEntry { + name, + params: params.into_iter().collect(), + values: vec![value.into()], + }); + } +} + +impl ICalendarEntry { + pub fn new(name: ICalendarProperty) -> Self { + Self { + name, + params: vec![], + values: vec![], + } + } + + pub fn with_params(mut self, params: Vec) -> Self { + self.params = params; + self + } + + pub fn with_value(mut self, value: impl Into) -> Self { + self.values.push(value.into()); + self + } + + pub fn with_values(mut self, values: Vec) -> Self { + self.values = values; + self + } + + pub fn with_param(mut self, param: impl Into) -> Self { + self.params.push(param.into()); + self + } + + pub fn add_param(&mut self, param: impl Into) { + self.params.push(param.into()); + } + + pub fn with_param_opt(mut self, param: Option>) -> Self { + if let Some(param) = param { + self.params.push(param.into()); + } + self + } + + pub fn is_type(&self, typ: &ICalendarValueType) -> bool { + self.parameters(&ICalendarParameterName::Value) + .any(|p| matches!(p, ICalendarParameterValue::Value(v) if v == typ)) + } +} + +impl ICalendarParameter { + pub fn new(name: ICalendarParameterName, value: impl Into) -> Self { + ICalendarParameter { + name, + value: value.into(), + } + } + + pub fn altrep(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Altrep, + value: value.into(), + } + } + + pub fn cn(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Cn, + value: value.into(), + } + } + + pub fn cutype(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Cutype, + value: value.into(), + } + } + + pub fn delegated_from(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::DelegatedFrom, + value: value.into(), + } + } + + pub fn delegated_to(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::DelegatedTo, + value: value.into(), + } + } + + pub fn dir(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Dir, + value: value.into(), + } + } + + pub fn fmttype(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Fmttype, + value: value.into(), + } + } + + pub fn fbtype(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Fbtype, + value: value.into(), + } + } + + pub fn language(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Language, + value: value.into(), + } + } + + pub fn member(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Member, + value: value.into(), + } + } + + pub fn partstat(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Partstat, + value: value.into(), + } + } + + pub fn range(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Range, + value: value.into(), + } + } + + pub fn related(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Related, + value: value.into(), + } + } + + pub fn reltype(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Reltype, + value: value.into(), + } + } + + pub fn role(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Role, + value: value.into(), + } + } + + pub fn rsvp(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Rsvp, + value: value.into(), + } + } + + pub fn schedule_agent(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::ScheduleAgent, + value: value.into(), + } + } + + pub fn schedule_force_send(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::ScheduleForceSend, + value: value.into(), + } + } + + pub fn schedule_status(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::ScheduleStatus, + value: value.into(), + } + } + + pub fn sent_by(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::SentBy, + value: value.into(), + } + } + + pub fn tzid(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Tzid, + value: value.into(), + } + } + + pub fn value(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Value, + value: value.into(), + } + } + + pub fn display(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Display, + value: value.into(), + } + } + + pub fn email(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Email, + value: value.into(), + } + } + + pub fn feature(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Feature, + value: value.into(), + } + } + + pub fn label(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Label, + value: value.into(), + } + } + + pub fn size(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Size, + value: value.into(), + } + } + + pub fn filename(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Filename, + value: value.into(), + } + } + + pub fn managed_id(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::ManagedId, + value: value.into(), + } + } + + pub fn order(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Order, + value: value.into(), + } + } + + pub fn schema(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Schema, + value: value.into(), + } + } + + pub fn derived(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Derived, + value: value.into(), + } + } + + pub fn gap(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Gap, + value: value.into(), + } + } + + pub fn linkrel(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Linkrel, + value: value.into(), + } + } + + pub fn jsptr(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Jsptr, + value: value.into(), + } + } + + pub fn jsid(value: impl Into) -> Self { + ICalendarParameter { + name: ICalendarParameterName::Jsid, + value: value.into(), + } + } +} + +impl From for ICalendarParameterValue { + fn from(text: String) -> Self { + ICalendarParameterValue::Text(text) + } +} + +impl From for ICalendarParameterValue { + fn from(integer: u64) -> Self { + ICalendarParameterValue::Integer(integer) + } +} + +impl From for ICalendarParameterValue { + fn from(integer: Integer) -> Self { + ICalendarParameterValue::Integer(integer.0.unsigned_abs()) + } +} + +impl From for ICalendarParameterValue { + fn from(boolean: Boolean) -> Self { + ICalendarParameterValue::Bool(boolean.0) + } +} + +impl From for ICalendarParameterValue { + fn from(boolean: bool) -> Self { + ICalendarParameterValue::Bool(boolean) + } +} + +impl From for ICalendarParameterValue { + fn from(uri: Uri) -> Self { + ICalendarParameterValue::Uri(uri) + } +} + +impl From for ICalendarParameterValue { + fn from(cutype: ICalendarUserTypes) -> Self { + ICalendarParameterValue::Cutype(cutype) + } +} + +impl From for ICalendarParameterValue { + fn from(fbtype: ICalendarFreeBusyType) -> Self { + ICalendarParameterValue::Fbtype(fbtype) + } +} + +impl From for ICalendarParameterValue { + fn from(partstat: ICalendarParticipationStatus) -> Self { + ICalendarParameterValue::Partstat(partstat) + } +} + +impl From for ICalendarParameterValue { + fn from(related: ICalendarRelated) -> Self { + ICalendarParameterValue::Related(related) + } +} + +impl From for ICalendarParameterValue { + fn from(reltype: ICalendarRelationshipType) -> Self { + ICalendarParameterValue::Reltype(reltype) + } +} + +impl From for ICalendarParameterValue { + fn from(role: ICalendarParticipationRole) -> Self { + ICalendarParameterValue::Role(role) + } +} + +impl From for ICalendarParameterValue { + fn from(schedule_agent: ICalendarScheduleAgentValue) -> Self { + ICalendarParameterValue::ScheduleAgent(schedule_agent) + } +} + +impl From for ICalendarParameterValue { + fn from(schedule_force_send: ICalendarScheduleForceSendValue) -> Self { + ICalendarParameterValue::ScheduleForceSend(schedule_force_send) + } +} + +impl From for ICalendarParameterValue { + fn from(value: ICalendarValueType) -> Self { + ICalendarParameterValue::Value(value) + } +} + +impl From for ICalendarParameterValue { + fn from(display: ICalendarDisplayType) -> Self { + ICalendarParameterValue::Display(display) + } +} + +impl From for ICalendarParameterValue { + fn from(feature: ICalendarFeatureType) -> Self { + ICalendarParameterValue::Feature(feature) + } +} + +impl From for ICalendarParameterValue { + fn from(duration: ICalendarDuration) -> Self { + ICalendarParameterValue::Duration(duration) + } +} + +impl From for ICalendarParameterValue { + fn from(linkrel: LinkRelation) -> Self { + ICalendarParameterValue::Linkrel(linkrel) + } +} + +impl> From> for ICalendarParameterValue { + fn from(value: IanaType) -> Self { + match value { + IanaType::Iana(v) => v.into(), + IanaType::Other(s) => s.into(), + } + } +} + +impl From> for ICalendarValue { + fn from(value: Vec) -> Self { + ICalendarValue::Binary(value) + } +} + +impl From for ICalendarValue { + fn from(value: bool) -> Self { + ICalendarValue::Boolean(value) + } +} + +impl From for ICalendarValue { + fn from(value: Uri) -> Self { + ICalendarValue::Uri(value) + } +} + +impl From for ICalendarValue { + fn from(value: PartialDateTime) -> Self { + ICalendarValue::PartialDateTime(Box::new(value)) + } +} + +impl From> for ICalendarValue { + fn from(value: Box) -> Self { + ICalendarValue::PartialDateTime(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarDuration) -> Self { + ICalendarValue::Duration(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarRecurrenceRule) -> Self { + ICalendarValue::RecurrenceRule(Box::new(value)) + } +} + +impl From> for ICalendarValue { + fn from(value: Box) -> Self { + ICalendarValue::RecurrenceRule(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarPeriod) -> Self { + ICalendarValue::Period(value) + } +} + +impl From for ICalendarValue { + fn from(value: f64) -> Self { + ICalendarValue::Float(value) + } +} + +impl From for ICalendarValue { + fn from(value: i64) -> Self { + ICalendarValue::Integer(value) + } +} + +impl From for ICalendarValue { + fn from(value: String) -> Self { + ICalendarValue::Text(value) + } +} + +impl From<&str> for ICalendarValue { + fn from(value: &str) -> Self { + ICalendarValue::Text(value.to_string()) + } +} + +impl From for ICalendarValue { + fn from(value: CalendarScale) -> Self { + ICalendarValue::CalendarScale(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarMethod) -> Self { + ICalendarValue::Method(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarClassification) -> Self { + ICalendarValue::Classification(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarStatus) -> Self { + ICalendarValue::Status(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarTransparency) -> Self { + ICalendarValue::Transparency(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarAction) -> Self { + ICalendarValue::Action(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarFreeBusyType) -> Self { + ICalendarValue::BusyType(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarParticipantType) -> Self { + ICalendarValue::ParticipantType(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarResourceType) -> Self { + ICalendarValue::ResourceType(value) + } +} + +impl From for ICalendarValue { + fn from(value: ICalendarProximityValue) -> Self { + ICalendarValue::Proximity(value) + } +} + +#[cfg(test)] +mod tests { + use crate::icalendar::ICalendar; + + #[test] + fn remove_component_ids() { + let mut ical = + ICalendar::parse(std::fs::read_to_string("resources/ical/007.ics").unwrap()).unwrap(); + ical.remove_component_ids(&[2, 5, 7]); + + let max_component_id = ical.components.len() as u32; + for component in &ical.components { + for id in &component.component_ids { + assert!(*id < max_component_id); + } + } + + assert_eq!( + ical.to_string().replace("\r\n", "\n"), + r#"BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:ANONYMOUS +X-WR-TIMEZONE:America/Denver +BEGIN:VTIMEZONE +TZID:America/Denver +X-LIC-LOCATION:America/Denver +BEGIN:STANDARD +TZOFFSETFROM:-0600 +TZOFFSETTO:-0700 +TZNAME:MST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=America/Denver:20200903T094000 +DTEND;TZID=America/Denver:20200903T095000 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE +EXDATE;TZID=America/Denver:20201015T094000 +DTSTAMP:20201124T214551Z +UID:078tk5i2t4all3kk0jcoi3mdmd@google.com +CREATED:20200903T032927Z +DESCRIPTION: +LAST-MODIFIED:20200903T032927Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Brain Break +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=America/Denver:20200903T095000 +DTEND;TZID=America/Denver:20200903T104000 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE +EXDATE;TZID=America/Denver:20201015T095000 +DTSTAMP:20201124T214551Z +UID:11le1ep09hvog7dbotn6foj38e@google.com +CREATED:20200903T032956Z +DESCRIPTION: +LAST-MODIFIED:20200903T032957Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Academic Time +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Budapest:20201102T072000 +DTEND;TZID=Europe/Budapest:20201102T072000 +EXDATE;TZID=Europe/Budapest:20201221T072000 +EXDATE;TZID=Europe/Budapest:20201222T072000 +EXDATE;TZID=Europe/Budapest:20201223T072000 +EXDATE;TZID=Europe/Budapest:20201224T072000 +EXDATE;TZID=Europe/Budapest:20201225T072000 +EXDATE;TZID=Europe/Budapest:20201228T072000 +EXDATE;TZID=Europe/Budapest:20201229T072000 +EXDATE;TZID=Europe/Budapest:20201230T072000 +EXDATE;TZID=Europe/Budapest:20201231T072000 +EXDATE;TZID=Europe/Budapest:20210101T072000 +RRULE;X-BUSYMAC-REGENERATE=TRASH:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR +DTSTAMP:20201230T095550Z +UID:*Masked away* +CREATED:20201102T054749Z +DESCRIPTION: +LAST-MODIFIED:20201221T100211Z +LOCATION: +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Invalid RRULE property +TRANSP:OPAQUE +X-BUSYMAC-LASTMODBY:*Masked away* +END:VEVENT +END:VCALENDAR +"# + ) + } +} diff --git a/vendor/calcard/src/icalendar/dates.rs b/vendor/calcard/src/icalendar/dates.rs new file mode 100644 index 00000000..6180369c --- /dev/null +++ b/vendor/calcard/src/icalendar/dates.rs @@ -0,0 +1,773 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +use super::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarPeriod, ICalendarProperty, + ICalendarValue, timezone::TzResolver, +}; +use crate::{ + common::{DateTimeResult, timezone::Tz}, + datecalc::{MAX_EXPANSION_WORK, RRuleIter, error::RRuleError, rrule::RRule}, + icalendar::ICalendarParameterName, +}; +use ahash::{AHashMap, AHashSet}; +use chrono::{DateTime, TimeDelta, TimeZone, Timelike}; +use std::fmt::{Display, Formatter}; + +#[allow(clippy::type_complexity)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub struct CalendarExpand { + pub events: Vec, TimeOrDelta, TimeDelta>>>, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub struct CalendarEvent { + pub comp_id: u32, + pub start: S, + pub end: E, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +pub enum TimeOrDelta { + Time(T), + Delta(D), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub struct CalendarError { + pub comp_id: u32, + pub error: CalendarErrorType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))] +pub enum CalendarErrorType { + MissingDtStart, + InvalidDtStart, + InvalidDtEnd, + InvalidDuration, + RRule(RRuleError), + // inbuxa: expansion stopped at this component's rule because all rules + // together used up `MAX_EXPANSION_WORK`; later rules weren't expanded + ExpansionBudgetExhausted, +} + +impl ICalendar { + pub fn expand_dates(&self, default_tz: impl Into, mut limit: usize) -> CalendarExpand { + let tz_resolver = self.build_tz_resolver().with_default(default_tz); + let mut expand = CalendarExpand::default(); + let mut rrules = Vec::new(); + let mut overridden = AHashMap::new(); + + for (comp_id, comp) in self.components.iter().enumerate() { + if comp.component_type.has_time_ranges() { + match comp.build_calendar_date(comp_id as u32, &tz_resolver, &mut expand.events) { + Ok(Some(event)) => { + if event.rrule.is_some() { + rrules.push((comp_id as u32, event)); + } else if let Some(rid) = event.rid { + overridden.insert((event.rrule_seq, rid), (comp_id as u32, event)); + } else if let Some(cal_event) = event.event { + expand.events.push(cal_event); + } + } + Ok(None) => {} + Err(error) => { + expand.errors.push(CalendarError { + comp_id: comp_id as u32, + error, + }); + } + } + } + } + + // Expand recurrences + let mut budget = MAX_EXPANSION_WORK; + for (mut comp_id, event) in rrules { + let rrule = event.rrule.unwrap(); + let floating_start = if let Some(floating_start) = Tz::Floating + .from_local_datetime(&event.dt_start.date_time) + .single() + { + floating_start + } else { + expand.errors.push(CalendarError { + comp_id, + error: CalendarErrorType::InvalidDtStart, + }); + continue; + }; + let rrule = match rrule.validate(floating_start) { + Ok(rrule) => rrule, + Err(err) => { + expand.errors.push(CalendarError { + comp_id, + error: CalendarErrorType::RRule(err), + }); + continue; + } + }; + let exdates = event + .exdates + .into_iter() + .filter_map(|(tz_id, dt)| { + dt.to_date_time_with_tz( + tz_resolver.resolve_or_default(tz_id.or(event.dt_start_tzid)), + ) + }) + .collect::>(); + let mut override_offset = None; + + // inbuxa: no instance can be added once the limit is reached + if limit == 0 { + continue; + } + let rule_comp_id = comp_id; + let mut dates = RRuleIter::new(&rrule, &floating_start, true).with_budget(budget); + + for date in dates.by_ref() { + if limit != 0 { + limit -= 1; + } else { + break; + } + let mut date = if date.timezone().is_floating() { + event + .start_tz + .from_local_datetime(&date.naive_local()) + .single() + .unwrap_or(date) + } else { + date + }; + if let Some(override_offset) = override_offset { + date += override_offset; + } + match overridden.remove(&(event.rrule_seq, date)) { + Some((new_comp_id, overridden_event)) => { + if let Some(new_event) = overridden_event.event { + if overridden_event.rid_this_and_future { + comp_id = new_comp_id; + override_offset = Some(new_event.start - date); + } + expand.events.push(new_event); + } + } + None if !exdates.contains(&date) => { + expand.events.push(CalendarEvent { + start: date, + end: TimeOrDelta::Delta(event.default_duration), + comp_id, + }); + } + _ => {} + } + } + + budget = dates.budget; + if dates.budget_exhausted { + expand.errors.push(CalendarError { + comp_id: rule_comp_id, + error: CalendarErrorType::ExpansionBudgetExhausted, + }); + break; + } + } + + // Add missing overridden events (this should not occur unless the iCalendar is malformed) + for (_, event) in overridden.into_values() { + if let Some(cal_event) = event.event { + expand.events.push(cal_event); + } + } + + expand + } +} + +#[allow(clippy::type_complexity)] +struct CalendarEventBuilder<'x> { + event: Option, TimeOrDelta, TimeDelta>>>, + dt_start: DateTimeResult, + dt_start_tzid: Option<&'x str>, + start_tz: Tz, + default_duration: TimeDelta, + rrule: Option, + rrule_seq: i64, + exdates: Vec<(Option<&'x str>, DateTimeResult)>, + rid: Option>, + rid_this_and_future: bool, +} + +impl ICalendarComponent { + #[allow(clippy::type_complexity)] + fn build_calendar_date( + &self, + comp_id: u32, + tz_resolver: &TzResolver<&'_ str>, + events: &mut Vec, TimeOrDelta, TimeDelta>>>, + ) -> Result>, CalendarErrorType> { + let mut dt_start = None; + let mut dt_start_tzid = None; + let mut dt_start_has_time = false; + let mut dt_end: Option = None; + let mut dt_end_tzid = None; + let mut todo_dates = vec![]; + let mut rid: Option = None; + let mut rid_tzid = None; + let mut rid_this_and_future = false; + let mut duration = None; + let mut rrule = None; + let mut rrule_seq = i64::MAX; + let mut rdates = vec![]; + let mut rdates_periods = vec![]; + let mut exdates = vec![]; + + for entry in &self.entries { + match (&entry.name, entry.values.first()) { + (ICalendarProperty::Dtstart, Some(ICalendarValue::PartialDateTime(dt))) => { + dt_start = dt.to_date_time(); + dt_start_tzid = entry.tz_id(); + dt_start_has_time = dt.has_time(); + } + (ICalendarProperty::Dtend, Some(ICalendarValue::PartialDateTime(dt))) => { + if let Some(dt) = dt.to_date_time() { + dt_end = Some(dt); + dt_end_tzid = entry.tz_id(); + } + } + ( + ICalendarProperty::Due + | ICalendarProperty::Completed + | ICalendarProperty::Created, + Some(ICalendarValue::PartialDateTime(dt)), + ) if self.component_type == ICalendarComponentType::VTodo => { + todo_dates.push((&entry.name, dt.to_date_time(), entry.tz_id())); + } + (ICalendarProperty::RecurrenceId, Some(ICalendarValue::PartialDateTime(dt))) => { + if let Some(dt) = dt.to_date_time() { + for param in &entry.params { + match ¶m.name { + ICalendarParameterName::Tzid => { + rid_tzid = param.value.as_text(); + } + ICalendarParameterName::Range => { + rid_this_and_future = true; + } + _ => (), + } + } + + rid = Some(dt); + } + } + (ICalendarProperty::Duration, Some(ICalendarValue::Duration(dur))) => { + duration = Some(dur); + } + (ICalendarProperty::Rrule, Some(ICalendarValue::RecurrenceRule(rule))) => { + rrule = RRule::from_floating_ical(rule); + } + (ICalendarProperty::Sequence, Some(ICalendarValue::Integer(seq))) => { + rrule_seq = *seq; + } + (ICalendarProperty::Rdate, _) => { + let tz_id = entry.tz_id(); + for value in &entry.values { + match value { + ICalendarValue::PartialDateTime(dt) => { + if let Some(dt) = dt.to_date_time() { + rdates.push((tz_id, dt)); + } + } + ICalendarValue::Period(period) => match period { + ICalendarPeriod::Range { start, end } => { + if let (Some(start), Some(end)) = + (start.to_date_time(), end.to_date_time()) + { + rdates_periods.push((tz_id, start, TimeOrDelta::Time(end))); + } + } + ICalendarPeriod::Duration { start, duration } => { + if let (Some(start), Some(duration)) = + (start.to_date_time(), duration.to_time_delta()) + { + rdates_periods.push(( + tz_id, + start, + TimeOrDelta::Delta(duration), + )); + } + } + }, + _ => (), + } + } + } + (ICalendarProperty::Exdate, _) => { + let tz_id = entry.tz_id(); + for value in &entry.values { + if let ICalendarValue::PartialDateTime(dt) = value + && let Some(dt) = dt.to_date_time() + { + exdates.push((tz_id, dt)); + } + } + } + _ => (), + } + } + + let dt_start = match dt_start { + Some(dt_start) => dt_start, + None => match &rid { + Some(rid) => { + dt_start_tzid = rid_tzid; + rid.clone() + } + None => match self.component_type { + ICalendarComponentType::VEvent => { + return Err(CalendarErrorType::MissingDtStart); + } + ICalendarComponentType::VTodo => { + let mut due_idx = None; + let mut completed_idx = None; + let mut created_idx = None; + + for (idx, (prop, dt, _)) in todo_dates.iter().enumerate() { + if dt.is_some() { + match prop { + ICalendarProperty::Due => due_idx = Some(idx), + ICalendarProperty::Completed => completed_idx = Some(idx), + ICalendarProperty::Created => created_idx = Some(idx), + _ => (), + } + } + } + + match (due_idx, completed_idx, created_idx) { + (Some(due_idx), _, _) => { + let due = &mut todo_dates[due_idx]; + dt_start_tzid = due.2; + dt_start_has_time = true; + due.1.take().unwrap() + } + (_, Some(completed_idx), Some(created_idx)) => { + let completed = &mut todo_dates[completed_idx]; + dt_end = completed.1.take(); + dt_end_tzid = completed.2; + + let created = &mut todo_dates[created_idx]; + dt_start_tzid = created.2; + created.1.take().unwrap() + } + (_, Some(date_idx), _) | (_, _, Some(date_idx)) => { + let date = &mut todo_dates[date_idx]; + dt_start_tzid = date.2; + dt_start_has_time = true; + date.1.take().unwrap() + } + _ => { + return Ok(None); + } + } + } + _ => { + return Ok(None); + } + }, + }, + }; + let mut event = None; + let start_tz = tz_resolver.resolve_or_default(dt_start_tzid); + let dt_start_tz = dt_start + .to_date_time_with_tz(start_tz) + .ok_or(CalendarErrorType::InvalidDtStart)?; + let default_duration = if let Some(dt_end) = dt_end { + let end = dt_end + .to_date_time_with_tz(tz_resolver.resolve_or_default(dt_end_tzid.or(dt_start_tzid))) + .ok_or(CalendarErrorType::InvalidDtEnd)?; + + if rrule.is_none() { + event = Some(CalendarEvent { + start: dt_start_tz, + end: TimeOrDelta::Time(end), + comp_id, + }); + } + dt_end.date_time - dt_start.date_time + } else if let Some(duration) = duration { + let duration = duration + .to_time_delta() + .ok_or(CalendarErrorType::InvalidDuration)?; + if rrule.is_none() { + event = Some(CalendarEvent { + start: dt_start_tz, + end: TimeOrDelta::Delta(duration), + comp_id, + }); + } + duration + } else if let Some((due, due_tzid)) = todo_dates + .into_iter() + .filter_map(|(prop, dt, tz_id)| { + if prop == &ICalendarProperty::Due { + dt.map(|dt| (dt, tz_id)) + } else { + None + } + }) + .next() + { + let end = due + .to_date_time_with_tz(tz_resolver.resolve_or_default(due_tzid.or(dt_start_tzid))) + .ok_or(CalendarErrorType::InvalidDtEnd)?; + + if rrule.is_none() { + event = Some(CalendarEvent { + start: dt_start_tz, + end: TimeOrDelta::Time(end), + comp_id, + }); + } + due.date_time - dt_start.date_time + } else { + /* + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day. For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. + */ + + let duration = if dt_start_has_time { + // If the start has time, we use the same time for the end + dt_start + .date_time + .with_hour(23) + .and_then(|dt| dt.with_minute(59)) + .and_then(|dt| dt.with_second(59)) + .map(|dt| dt - dt_start.date_time) + .unwrap_or_else(|| TimeDelta::days(1)) + } else { + TimeDelta::days(1) + }; + if rrule.is_none() { + event = Some(CalendarEvent { + start: dt_start_tz, + end: TimeOrDelta::Delta(duration), + comp_id, + }); + } + duration + }; + let rid = if let Some(rid) = rid { + rid.to_date_time_with_tz(tz_resolver.resolve_or_default(rid_tzid.or(dt_start_tzid))) + } else { + None + }; + + // Add rdates + for (tz_id, rdate) in rdates { + if let Some(date_start) = + rdate.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id.or(dt_start_tzid))) + { + events.push(CalendarEvent { + start: date_start, + end: TimeOrDelta::Delta(default_duration), + comp_id, + }); + } + } + for (tz_id, start, end) in rdates_periods { + let tz = tz_resolver.resolve_or_default(tz_id.or(dt_start_tzid)); + if let (Some(date_start), Some(date_end)) = ( + start.to_date_time_with_tz(tz), + end.into_date_time_with_tz(tz), + ) { + events.push(CalendarEvent { + start: date_start, + end: date_end, + comp_id, + }); + } + } + + Ok(Some(CalendarEventBuilder { + event, + dt_start_tzid, + default_duration, + rrule_seq, + rrule, + exdates, + start_tz, + dt_start, + rid, + rid_this_and_future, + })) + } +} + +impl TimeOrDelta { + pub fn into_date_time_with_tz(self, tz: Tz) -> Option, TimeDelta>> { + match self { + TimeOrDelta::Time(time) => time.to_date_time_with_tz(tz).map(TimeOrDelta::Time), + TimeOrDelta::Delta(delta) => Some(TimeOrDelta::Delta(delta)), + } + } +} + +impl CalendarEvent, TimeOrDelta, TimeDelta>> { + pub fn timestamps(&self) -> (i64, i64) { + let timestamp = self.start.timestamp(); + let end_timestamp = match self.end { + TimeOrDelta::Time(time) => time.timestamp(), + TimeOrDelta::Delta(delta) => timestamp + delta.num_seconds(), + }; + + (timestamp, end_timestamp) + } + + pub fn try_into_date_time(self) -> Option, DateTime>> { + match self.end { + TimeOrDelta::Time(time) => Some(time), + TimeOrDelta::Delta(delta) => self + .start + .naive_local() + .checked_add_signed(delta) + .and_then(|end| end.and_local_timezone(self.start.timezone()).single()), + } + .map(|end| CalendarEvent { + start: self.start, + end, + comp_id: self.comp_id, + }) + } +} + +impl Display for CalendarErrorType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CalendarErrorType::MissingDtStart => write!(f, "Missing DTSTART property"), + CalendarErrorType::InvalidDtStart => write!(f, "Invalid DTSTART property"), + CalendarErrorType::InvalidDtEnd => write!(f, "Invalid DTEND property"), + CalendarErrorType::InvalidDuration => write!(f, "Invalid DURATION property"), + CalendarErrorType::RRule(err) => write!(f, "RRule error: {err}"), + CalendarErrorType::ExpansionBudgetExhausted => { + write!(f, "Recurrence expansion stopped: work limit reached") + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + Entry, Parser, + common::timezone::Tz, + icalendar::dates::{CalendarError, CalendarEvent}, + }; + use chrono::DateTime; + use serde::Serialize; + use std::{io::Write, time::Instant}; + + #[test] + fn expand_rrule() { + // Read all .ics files in the test directory + for entry in std::fs::read_dir("resources/ical").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "ics") { + let input = match String::from_utf8(std::fs::read(&path).unwrap()) { + Ok(input) => input, + Err(err) => { + // ISO-8859-1 + err.as_bytes() + .iter() + .map(|&b| b as char) + .collect::() + } + }; + let mut parser = Parser::new(&input); + let mut output = None; + //let mut output_debug = + // std::fs::File::create(path.with_extension("ics.debug")).unwrap(); + let file_name = path.as_path().to_str().unwrap(); + + /*if file_name != "resources/ical/246.ics" { + continue; + }*/ + + #[derive(Serialize)] + struct TestResult { + errors: Vec, + events: Vec, DateTime>>, + } + + print!("Expanding recurrences for {file_name}... "); + let now = Instant::now(); + loop { + match parser.entry() { + Entry::ICalendar(ical) => { + let expanded = ical.expand_dates(chrono_tz::Tz::Pacific__Auckland, 100); + let mut events = expanded + .events + .into_iter() + .filter_map(|event| event.try_into_date_time()) + .collect::>(); + events.sort_by_key(|a| (a.start, a.comp_id)); + + for err in &expanded.errors { + print!("[{}: {:?}] ", err.comp_id, err.error); + } + + if !events.is_empty() || !expanded.errors.is_empty() { + writeln!( + output.get_or_insert_with(|| std::fs::File::create( + path.with_extension("json") + ) + .unwrap()), + "{}", + serde_json::to_string_pretty(&TestResult { + errors: expanded.errors, + events, + }) + .unwrap() + ) + .unwrap(); + } + } + Entry::InvalidLine(_) => {} + Entry::Eof => { + println!(" (done in {:?})", now.elapsed()); + break; + } + other => { + panic!("Expected iCal, got {other:?} for {file_name}"); + } + } + } + } + } + } +} + +// inbuxa: one budget bounds the work of all the rules in a calendar +#[cfg(test)] +mod budget_tests { + use crate::{ + datecalc::{MAX_EXPANSION_WORK, rrule_iter::ITERATIONS}, + icalendar::{ + ICalendar, + dates::{CalendarErrorType, CalendarExpand}, + }, + }; + + fn calendar(rule: &str, components: usize) -> ICalendar { + let mut ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n".to_string(); + for _ in 0..components { + ical.push_str(&format!( + "BEGIN:VEVENT\r\nUID:budget\r\nDTSTART:20000101T000000Z\r\n\ + DTEND:20000101T010000Z\r\nRRULE:{rule}\r\nEND:VEVENT\r\n" + )); + } + ical.push_str("END:VCALENDAR\r\n"); + ICalendar::parse(&ical).unwrap() + } + + // Expands and returns the result with the passes and skipped times it took + fn expand(rule: &str, components: usize, limit: usize) -> (CalendarExpand, u64) { + let ical = calendar(rule, components); + ITERATIONS.with(|count| count.set(0)); + let expanded = ical.expand_dates(chrono_tz::Tz::UTC, limit); + (expanded, ITERATIONS.with(|count| count.get())) + } + + // The budget, and the pass that runs over it + fn within_budget() -> u64 { + u64::from(MAX_EXPANSION_WORK) + 10_000 + } + + fn stopped(expanded: &CalendarExpand) -> bool { + expanded + .errors + .iter() + .any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted) + } + + #[test] + fn never_matching_rules_share_one_budget() { + // Each rule alone spends calcard's 100,000 passes looking for a 31 February + let (expanded, iterations) = expand("FREQ=DAILY;BYMONTH=2;BYMONTHDAY=31", 40, 3000); + println!("40 never-matching rules: {iterations} iterations"); + assert!(iterations <= within_budget(), "{iterations}"); + assert!(expanded.events.is_empty()); + assert!(stopped(&expanded)); + } + + #[test] + fn sparse_rules_share_one_budget() { + // One instance every four years, each found after ~1,460 daily passes, + // and one a year found by skipping every other minute of the year + for rule in [ + "FREQ=DAILY;BYMONTH=2;BYMONTHDAY=29", + "FREQ=MINUTELY;BYMONTH=1;BYMONTHDAY=1;BYHOUR=1;BYMINUTE=1", + ] { + let (expanded, iterations) = expand(rule, 1, 3000); + println!( + "{rule}: {} instances, {iterations} iterations", + expanded.events.len() + ); + assert!(iterations <= within_budget(), "{rule}: {iterations}"); + assert!(!expanded.events.is_empty(), "{rule}"); + assert!(stopped(&expanded), "{rule}"); + } + + // A year scanned for nothing costs more than a day scanned for nothing + let (expanded, iterations) = expand("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=31", 20, 3000); + println!("20 never-matching yearly rules: {iterations} passes"); + assert!(iterations < 100_000, "{iterations}"); + assert!(stopped(&expanded)); + } + + #[test] + fn ordinary_rules_expand_in_full() { + for (rule, instances) in [ + ("FREQ=DAILY", 3000), + ("FREQ=WEEKLY;BYDAY=MO,WE,FR", 3000), + ("FREQ=MONTHLY;BYDAY=-1FR", 3000), + ("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 3000), + ("FREQ=YEARLY", 3000), + // every leap day to the year 10000 + ("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29", 1941), + ("FREQ=HOURLY;BYHOUR=9,17", 3000), + ("FREQ=MINUTELY;INTERVAL=15;BYHOUR=9,10,11", 3000), + ] { + let (expanded, _) = expand(rule, 1, 3000); + assert_eq!(expanded.events.len(), instances, "{rule}"); + assert!(expanded.errors.is_empty(), "{rule}: {:?}", expanded.errors); + } + + // Rules after the instance limit is reached cost nothing + let (expanded, iterations) = expand("FREQ=DAILY", 200, 3000); + assert_eq!(expanded.events.len(), 3000); + assert!(expanded.errors.is_empty()); + assert!(iterations < 4000, "{iterations}"); + } +} diff --git a/vendor/calcard/src/icalendar/mod.rs b/vendor/calcard/src/icalendar/mod.rs new file mode 100644 index 00000000..9f68dd72 --- /dev/null +++ b/vendor/calcard/src/icalendar/mod.rs @@ -0,0 +1,902 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::common::{ + CalendarScale, Data, IanaParse, IanaString, IanaType, LinkRelation, PartialDateTime, +}; +use std::hash::{Hash, Hasher}; + +pub mod builder; +pub mod dates; +pub mod parser; +pub mod timezone; +pub mod types; +pub mod utils; +pub mod writer; + +#[cfg(feature = "rkyv")] +pub mod rkyv_timezone; +#[cfg(feature = "rkyv")] +pub mod rkyv_types; +#[cfg(feature = "rkyv")] +pub mod rkyv_utils; +#[cfg(feature = "rkyv")] +pub mod rkyv_writer; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct ICalendar { + pub components: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct ICalendarComponent { + pub component_type: ICalendarComponentType, + pub entries: Vec, + pub component_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct ICalendarEntry { + pub name: ICalendarProperty, + pub params: Vec, + pub values: Vec, +} + +#[derive(Debug, Clone, PartialEq, PartialOrd)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub enum ICalendarValue { + Binary(Vec), + Boolean(bool), + Uri(Uri), + PartialDateTime(Box), + Duration(ICalendarDuration), + RecurrenceRule(Box), + Period(ICalendarPeriod), + Float(f64), + Integer(i64), + Text(String), + CalendarScale(CalendarScale), + Method(ICalendarMethod), + Classification(ICalendarClassification), + Status(ICalendarStatus), + Transparency(ICalendarTransparency), + Action(ICalendarAction), + BusyType(ICalendarFreeBusyType), + ParticipantType(ICalendarParticipantType), + ResourceType(ICalendarResourceType), + Proximity(ICalendarProximityValue), +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub struct ICalendarRecurrenceRule { + pub freq: ICalendarFrequency, + pub until: Option, + pub count: Option, + pub interval: Option, + pub bysecond: Vec, + pub byminute: Vec, + pub byhour: Vec, + pub byday: Vec, + pub bymonthday: Vec, + pub byyearday: Vec, + pub byweekno: Vec, + pub bymonth: Vec, + pub bysetpos: Vec, + pub wkst: Option, + pub rscale: Option, + pub skip: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +#[repr(u8)] +pub enum ICalendarSkip { + Omit, + Backward, + Forward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub struct ICalendarDay { + pub ordwk: Option, + pub weekday: ICalendarWeekday, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +#[repr(transparent)] +pub struct ICalendarMonth(i8); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +#[repr(u8)] +pub enum ICalendarFrequency { + Yearly = 0, + Monthly = 1, + Weekly = 2, + #[default] + Daily = 3, + Hourly = 4, + Minutely = 5, + Secondly = 6, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarWeekday { + Sunday, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarPeriod { + Range { + start: PartialDateTime, + end: PartialDateTime, + }, + Duration { + start: PartialDateTime, + duration: ICalendarDuration, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarAction { + Audio, // [RFC5545, Section 3.8.6.1] + Display, // [RFC5545, Section 3.8.6.1] + Email, // [RFC5545, Section 3.8.6.1] + Procedure, // [RFC2445, Section 4.8.6.1] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarUserTypes { + Individual, // [RFC5545, Section 3.2.3] + Group, // [RFC5545, Section 3.2.3] + Resource, // [RFC5545, Section 3.2.3] + Room, // [RFC5545, Section 3.2.3] + Unknown, // [RFC5545, Section 3.2.3] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarClassification { + Public, // [RFC5545, Section 3.8.1.3] + Private, // [RFC5545, Section 3.8.1.3] + Confidential, // [RFC5545, Section 3.8.1.3] +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarComponentType { + Other(String), + #[default] + VCalendar, // [RFC5545, Section 3.4] + VEvent, // [RFC5545, Section 3.6.1] + VTodo, // [RFC5545, Section 3.6.2] + VJournal, // [RFC5545, Section 3.6.3] + VFreebusy, // [RFC5545, Section 3.6.4] + VTimezone, // [RFC5545, Section 3.6.5] + VAlarm, // [RFC5545, Section 3.6.6] + Standard, // [RFC5545, Section 3.6.5] + Daylight, // [RFC5545, Section 3.6.5] + VAvailability, // [RFC7953, Section 3.1] + Available, // [RFC7953, Section 3.1] + Participant, // [RFC9073, Section 7.1] + VLocation, // [RFC9073, Section 7.2] [RFC Errata 7381] + VResource, // [RFC9073, Section 7.3] + VStatus, // draft-ietf-calext-ical-tasks-14 +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarDisplayType { + Badge, // [RFC7986, Section 6.1] + Graphic, // [RFC7986, Section 6.1] + Fullsize, // [RFC7986, Section 6.1] + Thumbnail, // [RFC7986, Section 6.1] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarFeatureType { + Audio, // [RFC7986, Section 6.3] + Chat, // [RFC7986, Section 6.3] + Feed, // [RFC7986, Section 6.3] + Moderator, // [RFC7986, Section 6.3] + Phone, // [RFC7986, Section 6.3] + Screen, // [RFC7986, Section 6.3] + Video, // [RFC7986, Section 6.3] +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarFreeBusyType { + Free, // [RFC5545, Section 3.2.9] + Busy, // [RFC5545, Section 3.2.9] + BusyUnavailable, // [RFC5545, Section 3.2.9] + BusyTentative, // [RFC5545, Section 3.2.9] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarMethod { + Publish, // [RFC5546] + Request, // [RFC5546] + Reply, // [RFC5546] + Add, // [RFC5546] + Cancel, // [RFC5546] + Refresh, // [RFC5546] + Counter, // [RFC5546] + Declinecounter, // [RFC5546] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct ICalendarParameter { + pub name: ICalendarParameterName, + pub value: ICalendarParameterValue, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub enum ICalendarParameterValue { + Text(String), + Integer(u64), + Bool(bool), + Uri(Uri), + Cutype(ICalendarUserTypes), + Fbtype(ICalendarFreeBusyType), + Partstat(ICalendarParticipationStatus), + Related(ICalendarRelated), + Reltype(ICalendarRelationshipType), + Role(ICalendarParticipationRole), + ScheduleAgent(ICalendarScheduleAgentValue), + ScheduleForceSend(ICalendarScheduleForceSendValue), + Value(ICalendarValueType), + Display(ICalendarDisplayType), + Feature(ICalendarFeatureType), + Duration(ICalendarDuration), + Linkrel(LinkRelation), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(PartialEq)))] +pub enum ICalendarParameterName { + Other(String), + Altrep, // [RFC5545, Section 3.2.1] + Cn, // [RFC5545, Section 3.2.2] + Cutype, // [RFC5545, Section 3.2.3] + DelegatedFrom, // [RFC5545, Section 3.2.4] + DelegatedTo, // [RFC5545, Section 3.2.5] + Dir, // [RFC5545, Section 3.2.6] + Fmttype, // [RFC5545, Section 3.2.8] + Fbtype, // [RFC5545, Section 3.2.9] + Language, // [RFC5545, Section 3.2.10] + Member, // [RFC5545, Section 3.2.11] + Partstat, // [RFC5545, Section 3.2.12] + Range, // [RFC5545, Section 3.2.13] + Related, // [RFC5545, Section 3.2.14] + Reltype, // [RFC5545, Section 3.2.15] + Role, // [RFC5545, Section 3.2.16] + Rsvp, // [RFC5545, Section 3.2.17] + ScheduleAgent, // [RFC6638, Section 7.1] + ScheduleForceSend, // [RFC6638, Section 7.2] + ScheduleStatus, // [RFC6638, Section 7.3] + SentBy, // [RFC5545, Section 3.2.18] + Tzid, // [RFC5545, Section 3.2.19] + Value, // [RFC5545, Section 3.2.20] + Display, // [RFC7986, Section 6.1] + Email, // [RFC7986, Section 6.2] + Feature, // [RFC7986, Section 6.3] + Label, // [RFC7986, Section 6.4] + Size, // [RFC8607, Section 4.1] + Filename, // [RFC8607, Section 4.2] + ManagedId, // [RFC8607, Section 4.3] + Order, // [RFC9073, Section 5.1] + Schema, // [RFC9073, Section 5.2] + Derived, // [RFC9073, Section 5.3] + Gap, // [RFC9253, Section 6.2] + Linkrel, // [RFC9253, Section 6.1] + Jsptr, // draft-ietf-calext-jscalendar-icalendar + Jsid, // draft-ietf-calext-jscalendar-icalendar +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub struct ICalendarDuration { + pub neg: bool, + pub weeks: u32, + pub days: u32, + pub hours: u32, + pub minutes: u32, + pub seconds: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum Uri { + Data(Data), + Location(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarRelated { + Start, + End, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarParticipantType { + Active, // [RFC9073, Section 6.2] + Inactive, // [RFC9073, Section 6.2] + Sponsor, // [RFC9073, Section 6.2] + Contact, // [RFC9073, Section 6.2] + BookingContact, // [RFC9073, Section 6.2] + EmergencyContact, // [RFC9073, Section 6.2] + PublicityContact, // [RFC9073, Section 6.2] + PlannerContact, // [RFC9073, Section 6.2] + Performer, // [RFC9073, Section 6.2] + Speaker, // [RFC9073, Section 6.2] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarParticipationRole { + Chair, // [RFC5545, Section 3.2.16] + ReqParticipant, // [RFC5545, Section 3.2.16] + OptParticipant, // [RFC5545, Section 3.2.16] + NonParticipant, // [RFC5545, Section 3.2.16] + Owner, // JSCalendar +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarStatus { + Tentative, // [RFC5545, Section 3.8.1] + Confirmed, // [RFC5545, Section 3.8.1] + Cancelled, // [RFC5545, Section 3.8.1] + NeedsAction, // [RFC5545, Section 3.8.1] + Completed, // [RFC5545, Section 3.8.1] + InProcess, // [RFC5545, Section 3.8.1] + Draft, // [RFC5545, Section 3.8.1] + Final, // [RFC5545, Section 3.8.1] + Failed, // draft-ietf-calext-ical-tasks + Pending, // draft-ietf-calext-ical-tasks +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarParticipationStatus { + NeedsAction, // [RFC5545, Section 3.2.12] + Accepted, // [RFC5545, Section 3.2.12] + Declined, // [RFC5545, Section 3.2.12] + Tentative, // [RFC5545, Section 3.2.12] + Delegated, // [RFC5545, Section 3.2.12] + Completed, // [RFC5545, Section 3.2.12] + InProcess, // [RFC5545, Section 3.2.12] + Failed, // draft-ietf-calext-ical-tasks +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarProperty { + Begin, + End, + Other(String), + Calscale, // [RFC5545, Section 3.7.1] + Method, // [RFC5545, Section 3.7.2] + Prodid, // [RFC5545, Section 3.7.3] + Version, // [RFC5545, Section 3.7.4] + Attach, // [RFC5545, Section 3.8.1.1] + Categories, // [RFC5545, Section 3.8.1.2] [RFC7986, Section 5.6] + Class, // [RFC5545, Section 3.8.1.3] + Comment, // [RFC5545, Section 3.8.1.4] + Description, // [RFC5545, Section 3.8.1.5] [RFC7986, Section 5.2] + Geo, // [RFC5545, Section 3.8.1.6] + Location, // [RFC5545, Section 3.8.1.7] + PercentComplete, // [RFC5545, Section 3.8.1.8] + Priority, // [RFC5545, Section 3.8.1.9] + Resources, // [RFC5545, Section 3.8.1.10] + Status, // [RFC5545, Section 3.8.1.11] + Summary, // [RFC5545, Section 3.8.1.12] + Completed, // [RFC5545, Section 3.8.2.1] + Dtend, // [RFC5545, Section 3.8.2.2] + Due, // [RFC5545, Section 3.8.2.3] + Dtstart, // [RFC5545, Section 3.8.2.4] + Duration, // [RFC5545, Section 3.8.2.5] + Freebusy, // [RFC5545, Section 3.8.2.6] + Transp, // [RFC5545, Section 3.8.2.7] + Tzid, // [RFC5545, Section 3.8.3.1] + Tzname, // [RFC5545, Section 3.8.3.2] + Tzoffsetfrom, // [RFC5545, Section 3.8.3.3] + Tzoffsetto, // [RFC5545, Section 3.8.3.4] + Tzurl, // [RFC5545, Section 3.8.3.5] + Attendee, // [RFC5545, Section 3.8.4.1] + Contact, // [RFC5545, Section 3.8.4.2] + Organizer, // [RFC5545, Section 3.8.4.3] + RecurrenceId, // [RFC5545, Section 3.8.4.4] + RelatedTo, // [RFC5545, Section 3.8.4.5] [RFC9253, Section 9.1] + Url, // [RFC5545, Section 3.8.4.6] [RFC7986, Section 5.5] + Uid, // [RFC5545, Section 3.8.4.7] [RFC7986, Section 5.3] + Exdate, // [RFC5545, Section 3.8.5.1] + Exrule, // [RFC2445, Section 4.8.5.2] + Rdate, // [RFC5545, Section 3.8.5.2] + Rrule, // [RFC5545, Section 3.8.5.3] + Action, // [RFC5545, Section 3.8.6.1] + Repeat, // [RFC5545, Section 3.8.6.2] + Trigger, // [RFC5545, Section 3.8.6.3] + Created, // [RFC5545, Section 3.8.7.1] + Dtstamp, // [RFC5545, Section 3.8.7.2] + LastModified, // [RFC5545, Section 3.8.7.3] [RFC7986, Section 5.4] + Sequence, // [RFC5545, Section 3.8.7.4] + RequestStatus, // [RFC5545, Section 3.8.8.3] + Xml, // [RFC6321, Section 4.2] + Tzuntil, // [RFC7808, Section 7.1] + TzidAliasOf, // [RFC7808, Section 7.2] + Busytype, // [RFC7953, Section 3.2] + Name, // [RFC7986, Section 5.1] + RefreshInterval, // [RFC7986, Section 5.7] + Source, // [RFC7986, Section 5.8] + Color, // [RFC7986, Section 5.9] + Image, // [RFC7986, Section 5.10] + Conference, // [RFC7986, Section 5.11] + CalendarAddress, // [RFC9073, Section 6.4] + LocationType, // [RFC9073, Section 6.1] + ParticipantType, // [RFC9073, Section 6.2] + ResourceType, // [RFC9073, Section 6.3] + StructuredData, // [RFC9073, Section 6.6] + StyledDescription, // [RFC9073, Section 6.5] + Acknowledged, // [RFC9074, Section 6.1] + Proximity, // [RFC9074, Section 8.1] + Concept, // [RFC9253, Section 8.1] + Link, // [RFC9253, Section 8.2] + Refid, // [RFC9253, Section 8.3] + Coordinates, // draft-ietf-calext-icalendar-jscalendar-extensions + ShowWithoutTime, // draft-ietf-calext-icalendar-jscalendar-extensions + Jsid, // draft-ietf-calext-jscalendar-icalendar + Jsprop, // draft-ietf-calext-jscalendar-icalendar + EstimatedDuration, // draft-ietf-calext-ical-tasks + Reason, // draft-ietf-calext-ical-tasks + Substate, // draft-ietf-calext-ical-tasks + TaskMode, // draft-ietf-calext-ical-tasks +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarProximityValue { + Arrive, // [RFC9074, Section 8.1] + Depart, // [RFC9074, Section 8.1] + Connect, // [RFC9074, Section 8.1] + Disconnect, // [RFC9074, Section 8.1] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarRelationshipType { + Child, // [RFC5545, Section 3.2.15] + Parent, // [RFC5545, Section 3.2.15] + Sibling, // [RFC5545, Section 3.2.15] + Snooze, // [RFC9074, Section 7.1] + Concept, // [RFC9253, Section 5] + DependsOn, // [RFC9253, Section 5] + Finishtofinish, // [RFC9253, Section 4] + Finishtostart, // [RFC9253, Section 4] + First, // [RFC9253, Section 5] + Next, // [RFC9253, Section 5] + Refid, // [RFC9253, Section 5] + Starttofinish, // [RFC9253, Section 4] + Starttostart, // [RFC9253, Section 4] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarResourceType { + Projector, // [RFC9073, Section 6.3] + Room, // [RFC9073, Section 6.3] + RemoteConferenceAudio, // [RFC9073, Section 6.3] + RemoteConferenceVideo, // [RFC9073, Section 6.3] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarScheduleAgentValue { + Server, // [RFC6638, Section 7.1] + Client, // [RFC6638, Section 7.1] + None, // [RFC6638, Section 7.1] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarScheduleForceSendValue { + Request, // [RFC6638, Section 7.2] + Reply, // [RFC6638, Section 7.2] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarValueType { + Binary, // [RFC5545, Section 3.3.1] + Boolean, // [RFC5545, Section 3.3.2] + CalAddress, // [RFC5545, Section 3.3.3] + Date, // [RFC5545, Section 3.3.4] + DateTime, // [RFC5545, Section 3.3.5] + Duration, // [RFC5545, Section 3.3.6] + Float, // [RFC5545, Section 3.3.7] + Integer, // [RFC5545, Section 3.3.8] + Period, // [RFC5545, Section 3.3.9] + Recur, // [RFC5545, Section 3.3.10] + Text, // [RFC5545, Section 3.3.11] + Time, // [RFC5545, Section 3.3.12] + Unknown, // [RFC7265, Section 5] + Uri, // [RFC5545, Section 3.3.13] + UtcOffset, // [RFC5545, Section 3.3.14] + XmlReference, // [RFC9253, Section 7] + Uid, // [RFC9253, Section 7] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum ICalendarTransparency { + Opaque, + Transparent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueSeparator { + None, + Comma, + Semicolon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueType { + Ical(ICalendarValueType), + CalendarScale, + Method, + Classification, + Status, + Transparency, + Action, + BusyType, + ParticipantType, + ResourceType, + Proximity, +} diff --git a/vendor/calcard/src/icalendar/parser.rs b/vendor/calcard/src/icalendar/parser.rs new file mode 100644 index 00000000..6f96692f --- /dev/null +++ b/vendor/calcard/src/icalendar/parser.rs @@ -0,0 +1,2165 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; +use crate::{ + Entry, Parser, StopChar, Token, + common::{ + CalendarScale, Encoding, PartialDateTime, + parser::{Boolean, Integer, parse_digits, parse_small_digits}, + }, + icalendar::{ICalendarDay, ICalendarWeekday}, +}; +use mail_parser::decoders::{ + base64::base64_decode, charsets::map::charset_decoder, + quoted_printable::quoted_printable_decode, +}; +use std::{borrow::Cow, iter::Peekable, slice::Iter}; + +struct Params { + params: Vec, + stop_char: StopChar, + data_type: Option>, + charset: Option, + encoding: Option, +} + +impl Parser<'_> { + pub fn icalendar(&mut self, component_type: ICalendarComponentType) -> Entry { + let mut ical_stack: Vec = Vec::new(); + let mut ical_components = vec![ICalendarComponent { + component_type, + ..Default::default() + }]; + let mut next_component_id: u32 = 1; + let mut ical_idx = 0; + let mut ical = ical_components.last_mut().unwrap(); + + loop { + // Fetch property name + self.expect_iana_token(); + let token = match self.token() { + Some(token) => token, + None => break, + }; + + let mut params = Params { + params: Vec::new(), + stop_char: token.stop_char, + data_type: None, + encoding: None, + charset: None, + }; + + // Parse parameters + let name = token.text; + match params.stop_char { + StopChar::Semicolon => { + self.ical_parameters(&mut params); + } + StopChar::Colon => {} + StopChar::Lf => { + // Invalid line + if name.is_empty() || !self.strict { + continue; + } else { + return Entry::InvalidLine(Token::new(name).into_string()); + } + } + _ => {} + } + + // Invalid stop char, try seeking colon + if !matches!(params.stop_char, StopChar::Colon | StopChar::Lf) { + params.stop_char = self.seek_value_or_eol(); + } + + // Parse property + let name = match ICalendarProperty::parse(name.as_ref()) { + Some(ICalendarProperty::Begin) => { + if params.stop_char == StopChar::Colon { + self.expect_single_value(); + if let Some(token) = self.token() { + let component_type = ICalendarComponentType::parse(token.text.as_ref()) + .unwrap_or_else(|| { + ICalendarComponentType::Other(token.into_string()) + }); + ical_stack.push(ical_idx); + ical.component_ids.push(next_component_id); + ical_components.push(ICalendarComponent { + component_type, + ..Default::default() + }); + ical_idx = next_component_id as usize; + if let Some(id) = next_component_id.checked_add(1) { + next_component_id = id; + } else { + return Entry::TooManyComponents; + } + ical = ical_components.last_mut().unwrap(); + } + } + + if !self.strict { + continue; + } else { + return Entry::InvalidLine("BEGIN".to_string()); + } + } + Some(ICalendarProperty::End) => { + if params.stop_char == StopChar::Colon { + self.expect_single_value(); + if let Some(token) = self.token() { + let component_type = ICalendarComponentType::parse(token.text.as_ref()) + .unwrap_or_else(|| { + ICalendarComponentType::Other(token.into_string()) + }); + if ical.component_type == component_type || !self.strict { + if let Some(parent_ical_idx) = ical_stack.pop() { + ical_idx = parent_ical_idx; + ical = ical_components.get_mut(ical_idx).unwrap(); + } else { + break; + } + } else { + return Entry::UnexpectedComponentEnd { + expected: ical.component_type.clone(), + found: component_type, + }; + } + } + } + + if !self.strict { + continue; + } else { + return Entry::InvalidLine("END".to_string()); + } + } + Some(name) => name, + None => { + if !name.is_empty() { + ICalendarProperty::Other(Token::new(name).into_string()) + } else { + // Invalid line, skip + if params.stop_char != StopChar::Lf { + self.seek_lf(); + } + continue; + } + } + }; + let mut entry = ICalendarEntry { + name, + params: params.params, + values: Vec::new(), + }; + + // Parse value + if params.stop_char != StopChar::Lf { + // Obtain default type and separator + let (default_type, multi_value) = entry.name.default_types(); + match multi_value { + ValueSeparator::None => { + self.expect_single_value(); + } + ValueSeparator::Comma => { + self.expect_multi_value_comma(); + } + ValueSeparator::Semicolon => { + self.expect_multi_value_semicolon(); + } + } + + // Decode + match params.encoding { + Some(Encoding::Base64) if multi_value != ValueSeparator::None => { + self.expect_single_value(); + } + Some(Encoding::QuotedPrintable) => { + self.unfold_qp = true; + } + _ => {} + } + + if matches!( + (¶ms.data_type, &default_type), + (Some(IanaType::Iana(ICalendarValueType::Recur)), _) + | (None, ValueType::Ical(ICalendarValueType::Recur)) + ) { + match self.rrule() { + Ok(rrule) => { + entry + .values + .push(ICalendarValue::RecurrenceRule(Box::new(rrule))); + } + Err(other) => { + entry.values.push(ICalendarValue::Text(other)); + } + } + } else { + while let Some(mut token) = self.token() { + let eol = token.stop_char == StopChar::Lf; + + if token.text.is_empty() + && (matches!(multi_value, ValueSeparator::None) + || matches!(entry.name, ICalendarProperty::Other(_)) + && entry.values.is_empty()) + { + if eol { + break; + } else { + continue; + } + } + + // Decode binary parts + if let Some(encoding) = params.encoding { + let (bytes, default_encoding) = match encoding { + Encoding::Base64 => (base64_decode(&token.text), None), + Encoding::QuotedPrintable => { + (quoted_printable_decode(&token.text), "iso-8859-1".into()) + } + }; + if let Some(bytes) = bytes { + if let Some(decoded) = + params.charset.as_deref().or(default_encoding).and_then( + |charset| { + charset_decoder(charset.as_bytes()) + .map(|decoder| decoder(&bytes)) + }, + ) + { + token.text = Cow::Owned(decoded.into_bytes()); + } else if std::str::from_utf8(&bytes).is_ok() { + token.text = Cow::Owned(bytes); + } else { + entry.values.push(ICalendarValue::Binary(bytes)); + if eol { + break; + } else { + continue; + } + } + } + } + + match &default_type { + ValueType::Ical(default_type) => { + let value = match params + .data_type + .as_ref() + .map(|v| v.iana().unwrap_or(&ICalendarValueType::Text)) + .unwrap_or(default_type) + { + ICalendarValueType::Date => token + .into_ical_date() + .map(|data| ICalendarValue::PartialDateTime(Box::new(data))) + .unwrap_or_else(ICalendarValue::Text), + ICalendarValueType::DateTime => { + match token.into_timestamp(false) { + Ok(timestamp) => { + if !timestamp.has_time() { + params.data_type = Some(IanaType::Iana( + ICalendarValueType::Date, + )); + } + ICalendarValue::PartialDateTime(Box::new(timestamp)) + } + Err(other) => ICalendarValue::Text(other), + } + } + ICalendarValueType::Time => token + .into_ical_time() + .map(|data| ICalendarValue::PartialDateTime(Box::new(data))) + .unwrap_or_else(ICalendarValue::Text), + ICalendarValueType::UtcOffset => token + .into_offset() + .map(|data| ICalendarValue::PartialDateTime(Box::new(data))) + .unwrap_or_else(ICalendarValue::Text), + ICalendarValueType::Boolean => { + ICalendarValue::Boolean(token.into_boolean()) + } + ICalendarValueType::Float => token + .into_float() + .map(ICalendarValue::Float) + .unwrap_or_else(ICalendarValue::Text), + ICalendarValueType::Integer => token + .into_integer() + .map(ICalendarValue::Integer) + .unwrap_or_else(ICalendarValue::Text), + ICalendarValueType::Text + | ICalendarValueType::Binary + | ICalendarValueType::Unknown + | ICalendarValueType::XmlReference + | ICalendarValueType::Uid => { + ICalendarValue::Text(token.into_string()) + } + ICalendarValueType::Uri | ICalendarValueType::CalAddress => { + token + .into_uri_bytes() + .map(|data| ICalendarValue::Uri(Uri::Data(data))) + .unwrap_or_else(|uri| { + ICalendarValue::Uri(Uri::Location(uri)) + }) + } + ICalendarValueType::Duration => { + if let Some(duration) = + ICalendarDuration::parse(token.text.as_ref()) + { + ICalendarValue::Duration(duration) + } else { + ICalendarValue::Text(token.into_string()) + } + } + ICalendarValueType::Period => { + if let Some(period) = + ICalendarPeriod::parse(token.text.as_ref()) + { + ICalendarValue::Period(period) + } else { + ICalendarValue::Text(token.into_string()) + } + } + ICalendarValueType::Recur => unreachable!(), + }; + + entry.values.push(value); + } + ValueType::CalendarScale => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::CalendarScale(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Method => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Method(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Classification => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Classification(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Status => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Status(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Transparency => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Transparency(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Action => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Action(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::BusyType => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::BusyType(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::ParticipantType => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::ParticipantType(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::ResourceType => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::ResourceType(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + ValueType::Proximity => { + entry.values.push(match IanaType::from(token) { + IanaType::Iana(value) => ICalendarValue::Proximity(value), + IanaType::Other(value) => ICalendarValue::Text(value), + }); + } + } + + if eol { + break; + } + } + } + } + + // Skip begin and end properties + if !matches!( + entry.name, + ICalendarProperty::Begin | ICalendarProperty::End + ) { + // Add types + if let Some(data_type) = params.data_type { + entry.params.push(ICalendarParameter { + name: ICalendarParameterName::Value, + value: match data_type { + IanaType::Iana(value) => ICalendarParameterValue::Value(value), + IanaType::Other(value) => ICalendarParameterValue::Text(value), + }, + }); + } + + ical.entries.push(entry); + } + } + + if ical_stack.is_empty() || !self.strict { + Entry::ICalendar(ICalendar { + components: ical_components, + }) + } else { + Entry::UnterminatedComponent(ical.component_type.as_str().to_string().into()) + } + } + + fn ical_parameters(&mut self, params: &mut Params) { + while params.stop_char == StopChar::Semicolon { + self.expect_iana_token(); + let token = match self.token() { + Some(token) => token, + None => { + params.stop_char = StopChar::Lf; + break; + } + }; + + // Obtain parameter values + let param_name = token.text; + params.stop_char = token.stop_char; + if !matches!( + params.stop_char, + StopChar::Lf | StopChar::Colon | StopChar::Semicolon + ) { + if params.stop_char != StopChar::Equal { + params.stop_char = self.seek_param_value_or_eol(); + } + if params.stop_char == StopChar::Equal { + self.expect_param_value(); + while !matches!( + params.stop_char, + StopChar::Lf | StopChar::Colon | StopChar::Semicolon + ) { + match self.token() { + Some(token) => { + params.stop_char = token.stop_char; + self.token_buf.push(token); + } + None => { + params.stop_char = StopChar::Lf; + break; + } + } + } + } + } + + let param_values = &mut params.params; + if let Some(param_name) = ICalendarParameterName::try_parse(param_name.as_ref()) { + if self.token_buf.is_empty() { + param_values.push(ICalendarParameter::new( + param_name.clone(), + ICalendarParameterValue::Null, + )); + } + for token in self.token_buf.drain(..) { + match ¶m_name { + ICalendarParameterName::Altrep + | ICalendarParameterName::DelegatedFrom + | ICalendarParameterName::DelegatedTo + | ICalendarParameterName::Dir + | ICalendarParameterName::Member + | ICalendarParameterName::SentBy + | ICalendarParameterName::Schema => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + ICalendarParameterValue::Uri(Uri::from(token)), + )); + } + ICalendarParameterName::Rsvp | ICalendarParameterName::Derived => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Range => { + if token.text.as_ref().eq_ignore_ascii_case(b"THISANDFUTURE") { + param_values.push(ICalendarParameter::new( + param_name.clone(), + ICalendarParameterValue::Bool(true), + )); + } + } + ICalendarParameterName::Size | ICalendarParameterName::Order => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Gap => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Cutype => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Fbtype => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Partstat => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Related => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Reltype => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Role => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::ScheduleAgent => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::ScheduleForceSend => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Value => { + params.data_type = Some(token.into()); + } + ICalendarParameterName::Display => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Feature => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + ICalendarParameterName::Linkrel => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + IanaType::::from(token), + )); + } + _ => { + param_values.push(ICalendarParameter::new( + param_name.clone(), + ICalendarParameterValue::Text(token.into_string()), + )); + } + } + } + } else if !param_name.is_empty() { + match param_name.first() { + Some(b'c' | b'C') if param_name.as_ref().eq_ignore_ascii_case(b"charset") => { + for token in self.token_buf.drain(..) { + params.charset = token.into_string().into(); + } + } + Some(b'e' | b'E') if param_name.as_ref().eq_ignore_ascii_case(b"encoding") => { + for token in self.token_buf.drain(..) { + params.encoding = Encoding::parse(token.text.as_ref()); + } + } + _ => { + if params.encoding.is_none() && param_name.eq_ignore_ascii_case(b"base64") { + params.encoding = Some(Encoding::Base64); + } else { + let name = + ICalendarParameterName::Other(Token::new(param_name).into_string()) + .clone(); + + if !self.token_buf.is_empty() { + param_values.extend(self.token_buf.drain(..).map(|token| { + ICalendarParameter::new( + name.clone(), + ICalendarParameterValue::Text(token.into_string()), + ) + })); + } else { + param_values.push(ICalendarParameter::new( + name, + ICalendarParameterValue::Null, + )); + } + } + } + } + } + } + } + + pub(crate) fn rrule(&mut self) -> Result { + self.expect_rrule_value(); + + let mut last_stop_char = StopChar::Equal; + let mut is_valid = true; + let mut has_freq = false; + let mut rrule = ICalendarRecurrenceRule::default(); + + let mut token_start = usize::MAX; + + while let Some(mut token) = self.token_until_lf(&mut last_stop_char) { + if token_start == usize::MAX && !token.text.is_empty() { + token_start = token.start; + } + if !is_valid { + continue; + } + if token.text.is_empty() && token.stop_char != StopChar::Equal { + continue; + } + if token.stop_char != StopChar::Equal { + if !self.strict { + // Ignore unknown tokens + while let Some(token_) = self.token_until_lf(&mut last_stop_char) { + if token_.stop_char == StopChar::Equal { + token = token_; + break; + } + } + if token.stop_char != StopChar::Equal { + is_valid = false; + continue; + } + } else { + is_valid = false; + continue; + } + } + + hashify::fnc_map_ignore_case!(token.text.as_ref(), + b"FREQ" => { + while let Some(value) = self.parse_value_until_lf(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.freq = value; + has_freq = true; + } else if !self.strict { + is_valid = false; + } + } + }, + b"UNTIL" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.until = Some(value.0); + } else if !self.strict { + is_valid = false; + } + } + }, + b"COUNT" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + let count = value.0.unsigned_abs() as u32; + if count > 0 { + rrule.count = Some(count); + } + } else if !self.strict { + is_valid = false; + } + } + }, + b"INTERVAL" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + let interval = value.0.unsigned_abs() as u16; + if interval > 0 { + rrule.interval = Some(interval); + } + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYSECOND" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.bysecond.push(value.0.unsigned_abs() as u8); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYMINUTE" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.byminute.push(value.0.unsigned_abs() as u8); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYHOUR" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.byhour.push(value.0.unsigned_abs() as u8); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYDAY" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.byday.push(value); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYMONTHDAY" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.bymonthday.push(value.0 as i8); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYYEARDAY" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.byyearday.push(value.0 as i16); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYWEEKNO" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.byweekno.push(value.0 as i8); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYMONTH" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.bymonth.push(value); + } else if !self.strict { + is_valid = false; + } + } + }, + b"BYSETPOS" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.bysetpos.push(value.0 as i32); + } else if !self.strict { + is_valid = false; + } + } + }, + b"WKST" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.wkst = Some(value); + } else if !self.strict { + is_valid = false; + } + } + }, + b"RSCALE" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.rscale = Some(value); + } else if !self.strict { + is_valid = false; + } + } + }, + b"SKIP" => { + while let Some(value) = self.parse_value_until_lf::(StopChar::Semicolon, &mut last_stop_char) { if let Some(value)= value { + rrule.skip = Some(value); + } else if !self.strict { + is_valid = false; + } + } + }, + _ => { + if !self.strict { + // Ignore unknown tokens + while let Some(token) = self.token_until_lf(&mut last_stop_char) { if token.stop_char == StopChar::Semicolon{ + break; + } + } + } else { + is_valid = false; + } + } + ); + } + + if has_freq && is_valid { + Ok(rrule) + } else if token_start != usize::MAX && self.last_token_end >= token_start { + Err(self + .input + .get(token_start..=self.last_token_end) + .map(|slice| { + let mut buf = Vec::with_capacity(slice.len()); + let mut iter = slice.iter().peekable(); + + while let Some(ch) = iter.next() { + match ch { + b'\r' => {} + b'\n' => { + iter.next_if(|ch| matches!(ch, b' ' | b'\t')); + } + _ => buf.push(*ch), + } + } + + String::from_utf8(buf) + .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned()) + }) + .unwrap_or_default()) + } else { + Err("".to_string()) + } + } +} + +impl Token<'_> { + pub(crate) fn into_ical_date(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_ical_date(&mut self.text.iter().peekable()) { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_ical_time(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_ical_time(&mut self.text.iter().peekable()) { + Ok(dt) + } else { + Err(self.into_string()) + } + } +} + +impl PartialDateTime { + pub fn parse_ical_date(&mut self, iter: &mut Peekable>) -> bool { + parse_digits(iter, &mut self.year, 4, false) + && parse_small_digits(iter, &mut self.month, 2, false) + && parse_small_digits(iter, &mut self.day, 2, false) + } + + pub fn parse_ical_time(&mut self, iter: &mut Peekable>) -> bool { + if parse_small_digits(iter, &mut self.hour, 2, false) + && parse_small_digits(iter, &mut self.minute, 2, false) + && parse_small_digits(iter, &mut self.second, 2, false) + { + self.parse_zone(iter); + true + } else { + false + } + } +} + +struct ICalendarDateOrTime(PartialDateTime); + +impl IanaParse for ICalendarDateOrTime { + fn parse(value: &[u8]) -> Option { + let mut dt = PartialDateTime::default(); + if dt.parse_timestamp(&mut value.iter().peekable(), false) { + Some(ICalendarDateOrTime(dt)) + } else { + None + } + } +} + +impl IanaParse for ICalendarPeriod { + fn parse(value: &[u8]) -> Option { + let mut iter = value.iter().peekable(); + let mut start = PartialDateTime::default(); + if start.parse_timestamp(&mut iter, true) { + if let Some(duration) = ICalendarDuration::try_parse(&mut iter) { + Some(ICalendarPeriod::Duration { start, duration }) + } else { + let mut end = PartialDateTime::default(); + if end.parse_timestamp(&mut iter, true) { + Some(ICalendarPeriod::Range { start, end }) + } else { + None + } + } + } else { + None + } + } +} + +impl IanaParse for ICalendarMonth { + fn parse(value: &[u8]) -> Option { + let mut num: i8 = 0; + let mut is_leap = false; + + for (pos, ch) in value.iter().enumerate() { + match ch { + b'L' | b'l' if pos > 0 => { + is_leap = true; + } + b'0'..=b'9' => { + num = num.saturating_mul(10).saturating_add((*ch - b'0') as i8); + } + _ => { + if !ch.is_ascii_whitespace() { + return None; + } + } + } + } + + Some(ICalendarMonth(if is_leap { -num } else { num })) + } +} + +impl ICalendarMonth { + pub fn new(month: u8, is_leap: bool) -> Self { + ICalendarMonth(if is_leap { -(month as i8) } else { month as i8 }) + } + + pub fn is_leap(&self) -> bool { + self.0 < 0 + } + + pub fn month(&self) -> u8 { + self.0.unsigned_abs() + } +} + +impl From for u8 { + fn from(value: ICalendarMonth) -> Self { + value.month() + } +} + +impl ICalendarDuration { + fn try_parse(iter: &mut Peekable>) -> Option { + let mut dur = ICalendarDuration::default(); + loop { + match iter.peek() { + Some(b'P' | b'p') => { + iter.next(); + break; + } + Some(b'+') => { + iter.next(); + } + Some(b'-') if !dur.neg => { + iter.next(); + dur.neg = true; + } + Some(b' ' | b'\t') => { + iter.next(); + } + _ => { + return None; + } + } + } + + let mut num: u32 = 0; + let mut saw_component = false; + for ch in iter { + match ch { + b'0'..=b'9' => { + num = num.saturating_mul(10).saturating_add((ch - b'0') as u32); + } + b'T' | b't' => {} + b'W' | b'w' => { + dur.weeks = num; + num = 0; + saw_component = true; + } + b'D' | b'd' => { + dur.days = num; + num = 0; + saw_component = true; + } + b'H' | b'h' => { + dur.hours = num; + num = 0; + saw_component = true; + } + b'M' | b'm' => { + dur.minutes = num; + num = 0; + saw_component = true; + } + b'S' | b's' => { + dur.seconds = num; + num = 0; + saw_component = true; + } + _ => { + if !ch.is_ascii_whitespace() { + return None; + } + } + } + } + + if saw_component { Some(dur) } else { None } + } +} + +impl IanaParse for ICalendarDuration { + fn parse(value: &[u8]) -> Option { + ICalendarDuration::try_parse(&mut value.iter().peekable()) + } +} + +impl From> for Uri { + fn from(token: Token<'_>) -> Self { + token + .into_uri_bytes() + .map(Uri::Data) + .unwrap_or_else(Uri::Location) + } +} + +impl IanaParse for ICalendarDay { + fn parse(value: &[u8]) -> Option { + let mut iter = value.iter().enumerate(); + let mut is_negative = false; + let mut has_ordwk = false; + let mut ordwk: i16 = 0; + + loop { + let (pos, ch) = iter.next()?; + + match ch { + b'0'..=b'9' => { + ordwk = ordwk.saturating_mul(10).saturating_add((ch - b'0') as i16); + has_ordwk = true; + } + b'-' if pos == 0 => { + is_negative = true; + } + b'+' if pos == 0 => {} + b'A'..=b'Z' | b'a'..=b'z' => { + return ICalendarWeekday::parse(value.get(pos..).unwrap_or_default()).map( + |weekday| ICalendarDay { + ordwk: has_ordwk.then_some(if is_negative { -ordwk } else { ordwk }), + weekday, + }, + ); + } + _ => return None, + } + } + } +} + +impl Uri { + pub fn parse(value: impl Into) -> Self { + let uri = value.into(); + Data::try_parse(uri.as_bytes()) + .map(Uri::Data) + .unwrap_or_else(|| Uri::Location(uri)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn parse_ical() { + // Read all .ics files in the test directory + for entry in std::fs::read_dir("resources/ical").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "ics") { + let input = match String::from_utf8(std::fs::read(&path).unwrap()) { + Ok(input) => input, + Err(err) => { + // ISO-8859-1 + err.as_bytes() + .iter() + .map(|&b| b as char) + .collect::() + } + }; + let mut parser = Parser::new(&input); + let mut output = std::fs::File::create(path.with_extension("ics.out")).unwrap(); + //let mut output_debug = + // std::fs::File::create(path.with_extension("ics.debug")).unwrap(); + let file_name = path.as_path().to_str().unwrap(); + + loop { + match parser.entry() { + Entry::ICalendar(mut ical) => { + for item in &mut ical.components { + for item in &mut item.entries { + if item.name == ICalendarProperty::Version { + item.values = vec![ICalendarValue::Text("2.0".into())]; + } + } + } + let ical_text = ical.to_string(); + crate::common::writer::assert_fold_width(&ical_text, file_name); + writeln!(output, "{}", ical_text).unwrap(); + //writeln!(output_debug, "{:#?}", ical).unwrap(); + + // Roundtrip parsing + let mut parser = Parser::new(&ical_text); + match parser.entry() { + Entry::ICalendar(ical_) => { + /*ical.components.iter_mut().for_each(|component| { + component.entries.retain(|entry| { + !matches!(entry.name, ICalendarProperty::Version) + }); + }); + ical_.components.iter_mut().for_each(|component| { + component.entries.retain(|entry| { + !matches!(entry.name, ICalendarProperty::Version) + }); + });*/ + + compare_components(&ical, &ical_, file_name); + } + other => panic!("Expected iCal, got {other:?} for {file_name}"), + } + + // Rkyv archiving tests + #[cfg(feature = "rkyv")] + { + let ical_bytes = + rkyv::to_bytes::(&ical).unwrap(); + let ical_unarchived = rkyv::access::< + crate::icalendar::ArchivedICalendar, + rkyv::rancor::Error, + >(&ical_bytes) + .unwrap(); + assert_eq!(ical_text, ical_unarchived.to_string()); + } + } + Entry::InvalidLine(text) => { + println!("Invalid line in {file_name}: {text}"); + } + Entry::Eof => break, + other => { + panic!("Expected iCal, got {other:?} for {file_name}"); + } + } + } + } + } + } + + fn compare_components(a: &ICalendar, b: &ICalendar, file_name: &str) { + assert_eq!( + a.components.len(), + b.components.len(), + "failed for {file_name}" + ); + + for (a, b) in a.components.iter().zip(b.components.iter()) { + assert_eq!(a, b, "failed for {file_name}"); + } + } + + #[test] + fn test_parse_rrule() { + for (rule, expected) in [ + ( + "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Friday, + }, + ], + bysetpos: vec![-1], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYMONTH=1,2", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + bymonth: vec![ICalendarMonth(1), ICalendarMonth(2)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + interval: Some(2), + byminute: vec![30], + byhour: vec![8, 9], + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Sunday, + }], + bymonth: vec![ICalendarMonth(1)], + ..Default::default() + }, + ), + ( + "FREQ=DAILY;COUNT=10;INTERVAL=2", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + count: Some(10), + interval: Some(2), + ..Default::default() + }, + ), + ( + "FREQ=YEARLY", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + until: Some(PartialDateTime { + year: Some(1973), + month: Some(4), + day: Some(29), + hour: Some(7), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + byday: vec![ICalendarDay { + ordwk: Some(-1), + weekday: ICalendarWeekday::Sunday, + }], + bymonth: vec![ICalendarMonth(4)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + until: Some(PartialDateTime { + year: Some(2006), + month: Some(10), + day: Some(29), + hour: Some(6), + minute: Some(0), + second: Some(0), + tz_hour: None, + tz_minute: None, + tz_minus: false, + }), + byday: vec![ICalendarDay { + ordwk: Some(-1), + weekday: ICalendarWeekday::Sunday, + }], + bymonth: vec![ICalendarMonth(10)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: Some(2), + weekday: ICalendarWeekday::Sunday, + }], + bymonth: vec![ICalendarMonth(3)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: Some(-1), + weekday: ICalendarWeekday::Sunday, + }], + bymonth: vec![ICalendarMonth(10)], + ..Default::default() + }, + ), + ( + "FREQ=DAILY;COUNT=10", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + count: Some(10), + ..Default::default() + }, + ), + ( + "FREQ=DAILY;INTERVAL=10;COUNT=5", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + count: Some(5), + interval: Some(10), + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + until: Some(PartialDateTime { + year: Some(2000), + month: Some(1), + day: Some(31), + hour: Some(14), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Sunday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Friday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Saturday, + }, + ], + bymonth: vec![ICalendarMonth(1)], + ..Default::default() + }, + ), + ( + "FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + until: Some(PartialDateTime { + year: Some(2000), + month: Some(1), + day: Some(31), + hour: Some(14), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + bymonth: vec![ICalendarMonth(1)], + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;COUNT=10", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + count: Some(10), + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;INTERVAL=2;WKST=SU", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + interval: Some(2), + wkst: Some(ICalendarWeekday::Sunday), + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + until: Some(PartialDateTime { + year: Some(1997), + month: Some(10), + day: Some(7), + hour: Some(0), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ], + wkst: Some(ICalendarWeekday::Sunday), + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + count: Some(10), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ], + wkst: Some(ICalendarWeekday::Sunday), + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + until: Some(PartialDateTime { + year: Some(1997), + month: Some(12), + day: Some(24), + hour: Some(0), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + interval: Some(2), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Friday, + }, + ], + wkst: Some(ICalendarWeekday::Sunday), + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + count: Some(8), + interval: Some(2), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ], + wkst: Some(ICalendarWeekday::Sunday), + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;COUNT=10;BYDAY=1FR", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(10), + byday: vec![ICalendarDay { + ordwk: Some(1), + weekday: ICalendarWeekday::Friday, + }], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(10), + interval: Some(2), + byday: vec![ + ICalendarDay { + ordwk: Some(1), + weekday: ICalendarWeekday::Sunday, + }, + ICalendarDay { + ordwk: Some(-1), + weekday: ICalendarWeekday::Sunday, + }, + ], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;COUNT=6;BYDAY=-2MO", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(6), + byday: vec![ICalendarDay { + ordwk: Some(-2), + weekday: ICalendarWeekday::Monday, + }], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;BYMONTHDAY=-3", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + bymonthday: vec![-3], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(10), + bymonthday: vec![2, 15], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(10), + bymonthday: vec![1, -1], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(10), + interval: Some(18), + bymonthday: vec![10, 11, 12, 13, 14, 15], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;INTERVAL=2;BYDAY=TU", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + interval: Some(2), + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;COUNT=10;BYMONTH=6,7", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + count: Some(10), + bymonth: vec![ICalendarMonth(6), ICalendarMonth(7)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + count: Some(10), + interval: Some(2), + bymonth: vec![ICalendarMonth(1), ICalendarMonth(2), ICalendarMonth(3)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + count: Some(10), + interval: Some(3), + byyearday: vec![1, 100, 200], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYDAY=20MO", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: Some(20), + weekday: ICalendarWeekday::Monday, + }], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }], + byweekno: vec![20], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYMONTH=3;BYDAY=TH", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }], + bymonth: vec![ICalendarMonth(3)], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }], + bymonth: vec![ICalendarMonth(6), ICalendarMonth(7), ICalendarMonth(8)], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Friday, + }], + bymonthday: vec![13], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Saturday, + }], + bymonthday: vec![7, 8, 9, 10, 11, 12, 13], + ..Default::default() + }, + ), + ( + "FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + interval: Some(4), + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }], + bymonthday: vec![2, 3, 4, 5, 6, 7, 8], + bymonth: vec![ICalendarMonth(11)], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(3), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ], + bysetpos: vec![3], + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Thursday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Friday, + }, + ], + bysetpos: vec![-2], + ..Default::default() + }, + ), + ( + "FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Hourly, + until: Some(PartialDateTime { + year: Some(1997), + month: Some(9), + day: Some(2), + hour: Some(17), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }), + interval: Some(3), + ..Default::default() + }, + ), + ( + "FREQ=MINUTELY;INTERVAL=15;COUNT=6", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Minutely, + count: Some(6), + interval: Some(15), + ..Default::default() + }, + ), + ( + "FREQ=MINUTELY;INTERVAL=90;COUNT=4", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Minutely, + count: Some(4), + interval: Some(90), + ..Default::default() + }, + ), + ( + "FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + byminute: vec![0, 20, 40], + byhour: vec![9, 10, 11, 12, 13, 14, 15, 16], + ..Default::default() + }, + ), + ( + "FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Minutely, + interval: Some(20), + byhour: vec![9, 10, 11, 12, 13, 14, 15, 16], + ..Default::default() + }, + ), + ( + "FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + count: Some(4), + interval: Some(2), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Sunday, + }, + ], + wkst: Some(ICalendarWeekday::Monday), + ..Default::default() + }, + ), + ( + "FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5", + ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + count: Some(5), + bymonthday: vec![15, 30], + ..Default::default() + }, + ), + ] { + assert_eq!( + Parser::new(rule).strict().rrule().unwrap(), + expected, + "failed for {rule}" + ); + } + } + + #[test] + fn test_parse_period() { + for (rule, expected) in [ + ( + "19970308T160000Z/PT8H30M", + ICalendarPeriod::Duration { + start: PartialDateTime { + year: Some(1997), + month: Some(3), + day: Some(8), + hour: Some(16), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + duration: ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 8, + minutes: 30, + seconds: 0, + }, + }, + ), + ( + "19970308T160000/PT3H", + ICalendarPeriod::Duration { + start: PartialDateTime { + year: Some(1997), + month: Some(3), + day: Some(8), + hour: Some(16), + minute: Some(0), + second: Some(0), + tz_hour: None, + tz_minute: None, + tz_minus: false, + }, + duration: ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 3, + minutes: 0, + seconds: 0, + }, + }, + ), + ( + "19970308T200000Z/PT1H", + ICalendarPeriod::Duration { + start: PartialDateTime { + year: Some(1997), + month: Some(3), + day: Some(8), + hour: Some(20), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + duration: ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 1, + minutes: 0, + seconds: 0, + }, + }, + ), + ( + "19970308T230000Z/19970309T000000Z", + ICalendarPeriod::Range { + start: PartialDateTime { + year: Some(1997), + month: Some(3), + day: Some(8), + hour: Some(23), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + end: PartialDateTime { + year: Some(1997), + month: Some(3), + day: Some(9), + hour: Some(0), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + }, + ), + ( + "19970101T180000Z/PT5H30M", + ICalendarPeriod::Duration { + start: PartialDateTime { + year: Some(1997), + month: Some(1), + day: Some(1), + hour: Some(18), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + duration: ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 5, + minutes: 30, + seconds: 0, + }, + }, + ), + ( + "19971015T050000Z/PT8H30M", + ICalendarPeriod::Duration { + start: PartialDateTime { + year: Some(1997), + month: Some(10), + day: Some(15), + hour: Some(5), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + duration: ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 8, + minutes: 30, + seconds: 0, + }, + }, + ), + ( + "19980314T233000Z/19980315T003000Z", + ICalendarPeriod::Range { + start: PartialDateTime { + year: Some(1998), + month: Some(3), + day: Some(14), + hour: Some(23), + minute: Some(30), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + end: PartialDateTime { + year: Some(1998), + month: Some(3), + day: Some(15), + hour: Some(0), + minute: Some(30), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + tz_minus: false, + }, + }, + ), + ] { + let mut parser = Parser::new(rule); + let token = parser.token().unwrap(); + + assert_eq!( + ICalendarPeriod::parse(token.text.as_ref()).unwrap(), + expected + ); + } + } + + #[test] + fn test_parse_duration() { + for (rule, expected) in [ + ( + "P1W3DT4H5M6S", + ICalendarDuration { + neg: false, + weeks: 1, + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + }, + ), + ( + "P15DT5H0M20S", + ICalendarDuration { + neg: false, + weeks: 0, + days: 15, + hours: 5, + minutes: 0, + seconds: 20, + }, + ), + ( + "P7W", + ICalendarDuration { + neg: false, + weeks: 7, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }, + ), + ( + "-P7W", + ICalendarDuration { + neg: true, + weeks: 7, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }, + ), + ( + "PT0S", + ICalendarDuration { + neg: false, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }, + ), + ] { + let mut parser = Parser::new(rule); + let token = parser.token().unwrap(); + + assert_eq!( + ICalendarDuration::parse(token.text.as_ref()).expect(rule), + expected, + "Failed to parse: {rule}", + ); + } + + assert!(ICalendarDuration::parse(b"P").is_none()); + + // Duration zero + let zero = ICalendarDuration::default(); + assert_eq!(zero.to_string(), "PT0S"); + + let reparsed = ICalendarDuration::parse(zero.to_string().as_bytes()).unwrap(); + assert_eq!(reparsed, zero); + } +} diff --git a/vendor/calcard/src/icalendar/rkyv_timezone.rs b/vendor/calcard/src/icalendar/rkyv_timezone.rs new file mode 100644 index 00000000..62f78e1a --- /dev/null +++ b/vendor/calcard/src/icalendar/rkyv_timezone.rs @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + ArchivedICalendar, ArchivedICalendarComponent, ArchivedICalendarComponentType, + ArchivedICalendarEntry, ArchivedICalendarProperty, ArchivedICalendarValue, +}; +use crate::{common::timezone::Tz, icalendar::ICalendarParameterName}; +use std::str::FromStr; + +impl ArchivedICalendar { + pub fn timezones(&self) -> impl Iterator { + self.components.iter().filter(|comp| { + matches!( + comp.component_type, + ArchivedICalendarComponentType::VTimezone + ) + }) + } + + pub fn is_timezone(&self) -> bool { + self.timezones().count() == 1 + } +} + +impl ArchivedICalendarComponent { + pub fn timezone(&self) -> Option<(&str, Tz)> { + let mut tz_name = None; + let mut tz_id = None; + let mut tz_lic = None; + let mut tz_cdo_id = None; + + for entry in self.entries.iter() { + match (&entry.name, entry.values.first()) { + (ArchivedICalendarProperty::Tzid, Some(ArchivedICalendarValue::Text(id))) => { + tz_id = Tz::from_str(id.as_str()).ok(); + tz_name = Some(id.as_str()); + } + ( + ArchivedICalendarProperty::Other(value), + Some(ArchivedICalendarValue::Text(id)), + ) => { + hashify::fnc_map!(value.as_bytes(), + "X-LIC-LOCATION" => { + tz_lic = Tz::from_str(id.strip_prefix("SystemV/").unwrap_or(id.as_str())).ok(); + }, + "X-MICROSOFT-CDO-TZID" => { + tz_cdo_id = Tz::from_ms_cdo_zone_id(id); + }, + _ => {} + ); + } + _ => (), + } + } + + tz_name.zip(tz_id.or(tz_lic).or(tz_cdo_id)) + } +} + +impl ArchivedICalendarEntry { + pub fn tz_id(&self) -> Option<&str> { + self.parameters(&ICalendarParameterName::Tzid) + .find_map(|param| param.as_text()) + } +} diff --git a/vendor/calcard/src/icalendar/rkyv_types.rs b/vendor/calcard/src/icalendar/rkyv_types.rs new file mode 100644 index 00000000..c3bdb069 --- /dev/null +++ b/vendor/calcard/src/icalendar/rkyv_types.rs @@ -0,0 +1,871 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::common::ArchivedLinkRelation; + +use super::*; + +impl ArchivedICalendarProperty { + // Returns the default value type and whether the property is multi-valued. + pub(crate) fn default_types(&self) -> (ArchivedValueType, ValueSeparator) { + match self { + ArchivedICalendarProperty::Calscale => { + (ArchivedValueType::CalendarScale, ValueSeparator::None) + } + ArchivedICalendarProperty::Method => (ArchivedValueType::Method, ValueSeparator::None), + ArchivedICalendarProperty::Prodid => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Version => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Attach => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Categories => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::Class => { + (ArchivedValueType::Classification, ValueSeparator::None) + } + ArchivedICalendarProperty::Comment => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Description => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Geo => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Float), + ValueSeparator::Semicolon, + ), + ArchivedICalendarProperty::Location => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::PercentComplete => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Integer), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Priority => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Integer), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Resources => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::Status => (ArchivedValueType::Status, ValueSeparator::None), + ArchivedICalendarProperty::Summary => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Completed => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Dtend => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Due => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Dtstart => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Duration => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Duration), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Freebusy => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Period), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::Transp => { + (ArchivedValueType::Transparency, ValueSeparator::None) + } + ArchivedICalendarProperty::Tzid => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Tzname => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Tzoffsetfrom => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::UtcOffset), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Tzoffsetto => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::UtcOffset), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Tzurl => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Attendee => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Contact => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Organizer => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::RecurrenceId => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::RelatedTo => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Url => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Uid => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Exdate => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::Exrule => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Recur), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Rdate => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::Rrule => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Recur), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Action => (ArchivedValueType::Action, ValueSeparator::None), + ArchivedICalendarProperty::Repeat => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Integer), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Trigger => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Duration), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Created => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Dtstamp => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::LastModified => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Sequence => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Integer), + ValueSeparator::None, + ), + ArchivedICalendarProperty::RequestStatus => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::Semicolon, + ), + ArchivedICalendarProperty::Xml => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Tzuntil => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::TzidAliasOf => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Busytype => { + (ArchivedValueType::BusyType, ValueSeparator::None) + } + ArchivedICalendarProperty::Name => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::RefreshInterval => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Duration), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Source => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Color => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Image => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Conference => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::CalendarAddress => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::LocationType => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::Comma, + ), + ArchivedICalendarProperty::ParticipantType => { + (ArchivedValueType::ParticipantType, ValueSeparator::None) + } + ArchivedICalendarProperty::ResourceType => { + (ArchivedValueType::ResourceType, ValueSeparator::None) + } + ArchivedICalendarProperty::StructuredData => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::StyledDescription => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Acknowledged => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Proximity => { + (ArchivedValueType::Proximity, ValueSeparator::None) + } + ArchivedICalendarProperty::Concept => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Link => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Refid => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Begin => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::End => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Other(_) => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::Semicolon, + ), + ArchivedICalendarProperty::Coordinates => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::ShowWithoutTime => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Boolean), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Jsid | ArchivedICalendarProperty::Jsprop => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::EstimatedDuration => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Duration), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Reason => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Uri), + ValueSeparator::None, + ), + ArchivedICalendarProperty::Substate => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + ArchivedICalendarProperty::TaskMode => ( + ArchivedValueType::Ical(ArchivedICalendarValueType::Text), + ValueSeparator::None, + ), + } + } +} + +impl IanaString for ArchivedICalendarFrequency { + fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarFrequency::Secondly => "SECONDLY", + ArchivedICalendarFrequency::Minutely => "MINUTELY", + ArchivedICalendarFrequency::Hourly => "HOURLY", + ArchivedICalendarFrequency::Daily => "DAILY", + ArchivedICalendarFrequency::Weekly => "WEEKLY", + ArchivedICalendarFrequency::Monthly => "MONTHLY", + ArchivedICalendarFrequency::Yearly => "YEARLY", + } + } +} + +impl ArchivedICalendarSkip { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarSkip::Omit => "OMIT", + ArchivedICalendarSkip::Backward => "BACKWARD", + ArchivedICalendarSkip::Forward => "FORWARD", + } + } +} + +impl ArchivedICalendarWeekday { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarWeekday::Sunday => "SU", + ArchivedICalendarWeekday::Monday => "MO", + ArchivedICalendarWeekday::Tuesday => "TU", + ArchivedICalendarWeekday::Wednesday => "WE", + ArchivedICalendarWeekday::Thursday => "TH", + ArchivedICalendarWeekday::Friday => "FR", + ArchivedICalendarWeekday::Saturday => "SA", + } + } +} + +impl ArchivedICalendarAction { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarAction::Audio => "AUDIO", + ArchivedICalendarAction::Display => "DISPLAY", + ArchivedICalendarAction::Email => "EMAIL", + ArchivedICalendarAction::Procedure => "PROCEDURE", + } + } +} + +impl ArchivedICalendarUserTypes { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarUserTypes::Individual => "INDIVIDUAL", + ArchivedICalendarUserTypes::Group => "GROUP", + ArchivedICalendarUserTypes::Resource => "RESOURCE", + ArchivedICalendarUserTypes::Room => "ROOM", + ArchivedICalendarUserTypes::Unknown => "UNKNOWN", + } + } +} + +impl ArchivedICalendarClassification { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarClassification::Public => "PUBLIC", + ArchivedICalendarClassification::Private => "PRIVATE", + ArchivedICalendarClassification::Confidential => "CONFIDENTIAL", + } + } +} + +impl ArchivedICalendarComponentType { + pub fn as_str(&self) -> &str { + match self { + ArchivedICalendarComponentType::VCalendar => "VCALENDAR", + ArchivedICalendarComponentType::VEvent => "VEVENT", + ArchivedICalendarComponentType::VTodo => "VTODO", + ArchivedICalendarComponentType::VJournal => "VJOURNAL", + ArchivedICalendarComponentType::VFreebusy => "VFREEBUSY", + ArchivedICalendarComponentType::VTimezone => "VTIMEZONE", + ArchivedICalendarComponentType::VAlarm => "VALARM", + ArchivedICalendarComponentType::Standard => "STANDARD", + ArchivedICalendarComponentType::Daylight => "DAYLIGHT", + ArchivedICalendarComponentType::VAvailability => "VAVAILABILITY", + ArchivedICalendarComponentType::Available => "AVAILABLE", + ArchivedICalendarComponentType::Participant => "PARTICIPANT", + ArchivedICalendarComponentType::VLocation => "VLOCATION", + ArchivedICalendarComponentType::VResource => "VRESOURCE", + ArchivedICalendarComponentType::VStatus => "VSTATUS", + ArchivedICalendarComponentType::Other(name) => name.as_str(), + } + } +} + +impl ArchivedICalendarDisplayType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarDisplayType::Badge => "BADGE", + ArchivedICalendarDisplayType::Graphic => "GRAPHIC", + ArchivedICalendarDisplayType::Fullsize => "FULLSIZE", + ArchivedICalendarDisplayType::Thumbnail => "THUMBNAIL", + } + } +} + +impl ArchivedICalendarFeatureType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarFeatureType::Audio => "AUDIO", + ArchivedICalendarFeatureType::Chat => "CHAT", + ArchivedICalendarFeatureType::Feed => "FEED", + ArchivedICalendarFeatureType::Moderator => "MODERATOR", + ArchivedICalendarFeatureType::Phone => "PHONE", + ArchivedICalendarFeatureType::Screen => "SCREEN", + ArchivedICalendarFeatureType::Video => "VIDEO", + } + } +} + +impl ArchivedICalendarFreeBusyType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarFreeBusyType::Free => "FREE", + ArchivedICalendarFreeBusyType::Busy => "BUSY", + ArchivedICalendarFreeBusyType::BusyUnavailable => "BUSY-UNAVAILABLE", + ArchivedICalendarFreeBusyType::BusyTentative => "BUSY-TENTATIVE", + } + } +} + +impl ArchivedICalendarMethod { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarMethod::Publish => "PUBLISH", + ArchivedICalendarMethod::Request => "REQUEST", + ArchivedICalendarMethod::Reply => "REPLY", + ArchivedICalendarMethod::Add => "ADD", + ArchivedICalendarMethod::Cancel => "CANCEL", + ArchivedICalendarMethod::Refresh => "REFRESH", + ArchivedICalendarMethod::Counter => "COUNTER", + ArchivedICalendarMethod::Declinecounter => "DECLINECOUNTER", + } + } +} + +impl ArchivedICalendarParticipantType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarParticipantType::Active => "ACTIVE", + ArchivedICalendarParticipantType::Inactive => "INACTIVE", + ArchivedICalendarParticipantType::Sponsor => "SPONSOR", + ArchivedICalendarParticipantType::Contact => "CONTACT", + ArchivedICalendarParticipantType::BookingContact => "BOOKING-CONTACT", + ArchivedICalendarParticipantType::EmergencyContact => "EMERGENCY-CONTACT", + ArchivedICalendarParticipantType::PublicityContact => "PUBLICITY-CONTACT", + ArchivedICalendarParticipantType::PlannerContact => "PLANNER-CONTACT", + ArchivedICalendarParticipantType::Performer => "PERFORMER", + ArchivedICalendarParticipantType::Speaker => "SPEAKER", + } + } +} + +impl ArchivedICalendarParticipationRole { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarParticipationRole::Chair => "CHAIR", + ArchivedICalendarParticipationRole::ReqParticipant => "REQ-PARTICIPANT", + ArchivedICalendarParticipationRole::OptParticipant => "OPT-PARTICIPANT", + ArchivedICalendarParticipationRole::NonParticipant => "NON-PARTICIPANT", + ArchivedICalendarParticipationRole::Owner => "OWNER", + } + } +} + +impl ArchivedICalendarParticipationStatus { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarParticipationStatus::NeedsAction => "NEEDS-ACTION", + ArchivedICalendarParticipationStatus::Accepted => "ACCEPTED", + ArchivedICalendarParticipationStatus::Declined => "DECLINED", + ArchivedICalendarParticipationStatus::Tentative => "TENTATIVE", + ArchivedICalendarParticipationStatus::Delegated => "DELEGATED", + ArchivedICalendarParticipationStatus::Completed => "COMPLETED", + ArchivedICalendarParticipationStatus::InProcess => "IN-PROCESS", + ArchivedICalendarParticipationStatus::Failed => "FAILED", + } + } +} + +impl ArchivedICalendarStatus { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarStatus::Tentative => "TENTATIVE", + ArchivedICalendarStatus::Confirmed => "CONFIRMED", + ArchivedICalendarStatus::Cancelled => "CANCELLED", + ArchivedICalendarStatus::NeedsAction => "NEEDS-ACTION", + ArchivedICalendarStatus::Completed => "COMPLETED", + ArchivedICalendarStatus::InProcess => "IN-PROCESS", + ArchivedICalendarStatus::Draft => "DRAFT", + ArchivedICalendarStatus::Final => "FINAL", + ArchivedICalendarStatus::Failed => "FAILED", + ArchivedICalendarStatus::Pending => "PENDING", + } + } +} + +impl ArchivedICalendarProperty { + pub fn as_str(&self) -> &str { + match self { + ArchivedICalendarProperty::Calscale => "CALSCALE", + ArchivedICalendarProperty::Method => "METHOD", + ArchivedICalendarProperty::Prodid => "PRODID", + ArchivedICalendarProperty::Version => "VERSION", + ArchivedICalendarProperty::Attach => "ATTACH", + ArchivedICalendarProperty::Categories => "CATEGORIES", + ArchivedICalendarProperty::Class => "CLASS", + ArchivedICalendarProperty::Comment => "COMMENT", + ArchivedICalendarProperty::Description => "DESCRIPTION", + ArchivedICalendarProperty::Geo => "GEO", + ArchivedICalendarProperty::Location => "LOCATION", + ArchivedICalendarProperty::PercentComplete => "PERCENT-COMPLETE", + ArchivedICalendarProperty::Priority => "PRIORITY", + ArchivedICalendarProperty::Resources => "RESOURCES", + ArchivedICalendarProperty::Status => "STATUS", + ArchivedICalendarProperty::Summary => "SUMMARY", + ArchivedICalendarProperty::Completed => "COMPLETED", + ArchivedICalendarProperty::Dtend => "DTEND", + ArchivedICalendarProperty::Due => "DUE", + ArchivedICalendarProperty::Dtstart => "DTSTART", + ArchivedICalendarProperty::Duration => "DURATION", + ArchivedICalendarProperty::Freebusy => "FREEBUSY", + ArchivedICalendarProperty::Transp => "TRANSP", + ArchivedICalendarProperty::Tzid => "TZID", + ArchivedICalendarProperty::Tzname => "TZNAME", + ArchivedICalendarProperty::Tzoffsetfrom => "TZOFFSETFROM", + ArchivedICalendarProperty::Tzoffsetto => "TZOFFSETTO", + ArchivedICalendarProperty::Tzurl => "TZURL", + ArchivedICalendarProperty::Attendee => "ATTENDEE", + ArchivedICalendarProperty::Contact => "CONTACT", + ArchivedICalendarProperty::Organizer => "ORGANIZER", + ArchivedICalendarProperty::RecurrenceId => "RECURRENCE-ID", + ArchivedICalendarProperty::RelatedTo => "RELATED-TO", + ArchivedICalendarProperty::Url => "URL", + ArchivedICalendarProperty::Uid => "UID", + ArchivedICalendarProperty::Exdate => "EXDATE", + ArchivedICalendarProperty::Exrule => "EXRULE", + ArchivedICalendarProperty::Rdate => "RDATE", + ArchivedICalendarProperty::Rrule => "RRULE", + ArchivedICalendarProperty::Action => "ACTION", + ArchivedICalendarProperty::Repeat => "REPEAT", + ArchivedICalendarProperty::Trigger => "TRIGGER", + ArchivedICalendarProperty::Created => "CREATED", + ArchivedICalendarProperty::Dtstamp => "DTSTAMP", + ArchivedICalendarProperty::LastModified => "LAST-MODIFIED", + ArchivedICalendarProperty::Sequence => "SEQUENCE", + ArchivedICalendarProperty::RequestStatus => "REQUEST-STATUS", + ArchivedICalendarProperty::Xml => "XML", + ArchivedICalendarProperty::Tzuntil => "TZUNTIL", + ArchivedICalendarProperty::TzidAliasOf => "TZID-ALIAS-OF", + ArchivedICalendarProperty::Busytype => "BUSYTYPE", + ArchivedICalendarProperty::Name => "NAME", + ArchivedICalendarProperty::RefreshInterval => "REFRESH-INTERVAL", + ArchivedICalendarProperty::Source => "SOURCE", + ArchivedICalendarProperty::Color => "COLOR", + ArchivedICalendarProperty::Image => "IMAGE", + ArchivedICalendarProperty::Conference => "CONFERENCE", + ArchivedICalendarProperty::CalendarAddress => "CALENDAR-ADDRESS", + ArchivedICalendarProperty::LocationType => "LOCATION-TYPE", + ArchivedICalendarProperty::ParticipantType => "PARTICIPANT-TYPE", + ArchivedICalendarProperty::ResourceType => "RESOURCE-TYPE", + ArchivedICalendarProperty::StructuredData => "STRUCTURED-DATA", + ArchivedICalendarProperty::StyledDescription => "STYLED-DESCRIPTION", + ArchivedICalendarProperty::Acknowledged => "ACKNOWLEDGED", + ArchivedICalendarProperty::Proximity => "PROXIMITY", + ArchivedICalendarProperty::Concept => "CONCEPT", + ArchivedICalendarProperty::Link => "LINK", + ArchivedICalendarProperty::Refid => "REFID", + ArchivedICalendarProperty::Begin => "BEGIN", + ArchivedICalendarProperty::End => "END", + ArchivedICalendarProperty::Coordinates => "COORDINATES", + ArchivedICalendarProperty::ShowWithoutTime => "SHOW-WITHOUT-TIME", + ArchivedICalendarProperty::Jsid => "JSID", + ArchivedICalendarProperty::Jsprop => "JSPROP", + ArchivedICalendarProperty::EstimatedDuration => "ESTIMATED-DURATION", + ArchivedICalendarProperty::Reason => "REASON", + ArchivedICalendarProperty::Substate => "SUBSTATE", + ArchivedICalendarProperty::TaskMode => "TASK-MODE", + ArchivedICalendarProperty::Other(value) => value.as_str(), + } + } +} + +impl ArchivedICalendarProximityValue { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarProximityValue::Arrive => "ARRIVE", + ArchivedICalendarProximityValue::Depart => "DEPART", + ArchivedICalendarProximityValue::Connect => "CONNECT", + ArchivedICalendarProximityValue::Disconnect => "DISCONNECT", + } + } +} + +impl ArchivedICalendarRelationshipType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarRelationshipType::Child => "CHILD", + ArchivedICalendarRelationshipType::Parent => "PARENT", + ArchivedICalendarRelationshipType::Sibling => "SIBLING", + ArchivedICalendarRelationshipType::Snooze => "SNOOZE", + ArchivedICalendarRelationshipType::Concept => "CONCEPT", + ArchivedICalendarRelationshipType::DependsOn => "DEPENDS-ON", + ArchivedICalendarRelationshipType::Finishtofinish => "FINISHTOFINISH", + ArchivedICalendarRelationshipType::Finishtostart => "FINISHTOSTART", + ArchivedICalendarRelationshipType::First => "FIRST", + ArchivedICalendarRelationshipType::Next => "NEXT", + ArchivedICalendarRelationshipType::Refid => "REFID", + ArchivedICalendarRelationshipType::Starttofinish => "STARTTOFINISH", + ArchivedICalendarRelationshipType::Starttostart => "STARTTOSTART", + } + } +} + +impl ArchivedICalendarResourceType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarResourceType::Projector => "PROJECTOR", + ArchivedICalendarResourceType::Room => "ROOM", + ArchivedICalendarResourceType::RemoteConferenceAudio => "REMOTE-CONFERENCE-AUDIO", + ArchivedICalendarResourceType::RemoteConferenceVideo => "REMOTE-CONFERENCE-VIDEO", + } + } +} + +impl ArchivedICalendarScheduleAgentValue { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarScheduleAgentValue::Server => "SERVER", + ArchivedICalendarScheduleAgentValue::Client => "CLIENT", + ArchivedICalendarScheduleAgentValue::None => "NONE", + } + } +} + +impl ArchivedICalendarScheduleForceSendValue { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarScheduleForceSendValue::Request => "REQUEST", + ArchivedICalendarScheduleForceSendValue::Reply => "REPLY", + } + } +} + +impl ArchivedICalendarValueType { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarValueType::Binary => "BINARY", + ArchivedICalendarValueType::Boolean => "BOOLEAN", + ArchivedICalendarValueType::CalAddress => "CAL-ADDRESS", + ArchivedICalendarValueType::Date => "DATE", + ArchivedICalendarValueType::DateTime => "DATE-TIME", + ArchivedICalendarValueType::Duration => "DURATION", + ArchivedICalendarValueType::Float => "FLOAT", + ArchivedICalendarValueType::Integer => "INTEGER", + ArchivedICalendarValueType::Period => "PERIOD", + ArchivedICalendarValueType::Recur => "RECUR", + ArchivedICalendarValueType::Text => "TEXT", + ArchivedICalendarValueType::Time => "TIME", + ArchivedICalendarValueType::Unknown => "UNKNOWN", + ArchivedICalendarValueType::Uri => "URI", + ArchivedICalendarValueType::UtcOffset => "UTC-OFFSET", + ArchivedICalendarValueType::XmlReference => "XML-REFERENCE", + ArchivedICalendarValueType::Uid => "UID", + } + } +} + +impl ArchivedICalendarTransparency { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarTransparency::Opaque => "OPAQUE", + ArchivedICalendarTransparency::Transparent => "TRANSPARENT", + } + } +} + +impl ArchivedLinkRelation { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedLinkRelation::About => "about", + ArchivedLinkRelation::Acl => "acl", + ArchivedLinkRelation::Alternate => "alternate", + ArchivedLinkRelation::Amphtml => "amphtml", + ArchivedLinkRelation::ApiCatalog => "api-catalog", + ArchivedLinkRelation::Appendix => "appendix", + ArchivedLinkRelation::AppleTouchIcon => "apple-touch-icon", + ArchivedLinkRelation::AppleTouchStartupImage => "apple-touch-startup-image", + ArchivedLinkRelation::Archives => "archives", + ArchivedLinkRelation::Author => "author", + ArchivedLinkRelation::BlockedBy => "blocked-by", + ArchivedLinkRelation::Bookmark => "bookmark", + ArchivedLinkRelation::C2paManifest => "c2pa-manifest", + ArchivedLinkRelation::Canonical => "canonical", + ArchivedLinkRelation::Chapter => "chapter", + ArchivedLinkRelation::CiteAs => "cite-as", + ArchivedLinkRelation::Collection => "collection", + ArchivedLinkRelation::CompressionDictionary => "compression-dictionary", + ArchivedLinkRelation::Contents => "contents", + ArchivedLinkRelation::Convertedfrom => "convertedfrom", + ArchivedLinkRelation::Copyright => "copyright", + ArchivedLinkRelation::CreateForm => "create-form", + ArchivedLinkRelation::Current => "current", + ArchivedLinkRelation::Deprecation => "deprecation", + ArchivedLinkRelation::Describedby => "describedby", + ArchivedLinkRelation::Describes => "describes", + ArchivedLinkRelation::Disclosure => "disclosure", + ArchivedLinkRelation::DnsPrefetch => "dns-prefetch", + ArchivedLinkRelation::Duplicate => "duplicate", + ArchivedLinkRelation::Edit => "edit", + ArchivedLinkRelation::EditForm => "edit-form", + ArchivedLinkRelation::EditMedia => "edit-media", + ArchivedLinkRelation::Enclosure => "enclosure", + ArchivedLinkRelation::External => "external", + ArchivedLinkRelation::First => "first", + ArchivedLinkRelation::Geofeed => "geofeed", + ArchivedLinkRelation::Glossary => "glossary", + ArchivedLinkRelation::Help => "help", + ArchivedLinkRelation::Hosts => "hosts", + ArchivedLinkRelation::Hub => "hub", + ArchivedLinkRelation::IceServer => "ice-server", + ArchivedLinkRelation::Icon => "icon", + ArchivedLinkRelation::Index => "index", + ArchivedLinkRelation::Intervalafter => "intervalafter", + ArchivedLinkRelation::Intervalbefore => "intervalbefore", + ArchivedLinkRelation::Intervalcontains => "intervalcontains", + ArchivedLinkRelation::Intervaldisjoint => "intervaldisjoint", + ArchivedLinkRelation::Intervalduring => "intervalduring", + ArchivedLinkRelation::Intervalequals => "intervalequals", + ArchivedLinkRelation::Intervalfinishedby => "intervalfinishedby", + ArchivedLinkRelation::Intervalfinishes => "intervalfinishes", + ArchivedLinkRelation::Intervalin => "intervalin", + ArchivedLinkRelation::Intervalmeets => "intervalmeets", + ArchivedLinkRelation::Intervalmetby => "intervalmetby", + ArchivedLinkRelation::Intervaloverlappedby => "intervaloverlappedby", + ArchivedLinkRelation::Intervaloverlaps => "intervaloverlaps", + ArchivedLinkRelation::Intervalstartedby => "intervalstartedby", + ArchivedLinkRelation::Intervalstarts => "intervalstarts", + ArchivedLinkRelation::Item => "item", + ArchivedLinkRelation::Last => "last", + ArchivedLinkRelation::LatestVersion => "latest-version", + ArchivedLinkRelation::License => "license", + ArchivedLinkRelation::Linkset => "linkset", + ArchivedLinkRelation::Lrdd => "lrdd", + ArchivedLinkRelation::Manifest => "manifest", + ArchivedLinkRelation::MaskIcon => "mask-icon", + ArchivedLinkRelation::Me => "me", + ArchivedLinkRelation::MediaFeed => "media-feed", + ArchivedLinkRelation::Memento => "memento", + ArchivedLinkRelation::Micropub => "micropub", + ArchivedLinkRelation::Modulepreload => "modulepreload", + ArchivedLinkRelation::Monitor => "monitor", + ArchivedLinkRelation::MonitorGroup => "monitor-group", + ArchivedLinkRelation::Next => "next", + ArchivedLinkRelation::NextArchive => "next-archive", + ArchivedLinkRelation::Nofollow => "nofollow", + ArchivedLinkRelation::Noopener => "noopener", + ArchivedLinkRelation::Noreferrer => "noreferrer", + ArchivedLinkRelation::Opener => "opener", + ArchivedLinkRelation::Openid2LocalId => "openid2.local_id", + ArchivedLinkRelation::Openid2Provider => "openid2.provider", + ArchivedLinkRelation::Original => "original", + ArchivedLinkRelation::P3pv1 => "p3pv1", + ArchivedLinkRelation::Payment => "payment", + ArchivedLinkRelation::Pingback => "pingback", + ArchivedLinkRelation::Preconnect => "preconnect", + ArchivedLinkRelation::PredecessorVersion => "predecessor-version", + ArchivedLinkRelation::Prefetch => "prefetch", + ArchivedLinkRelation::Preload => "preload", + ArchivedLinkRelation::Prerender => "prerender", + ArchivedLinkRelation::Prev => "prev", + ArchivedLinkRelation::Preview => "preview", + ArchivedLinkRelation::Previous => "previous", + ArchivedLinkRelation::PrevArchive => "prev-archive", + ArchivedLinkRelation::PrivacyPolicy => "privacy-policy", + ArchivedLinkRelation::Profile => "profile", + ArchivedLinkRelation::Publication => "publication", + ArchivedLinkRelation::RdapActive => "rdap-active", + ArchivedLinkRelation::RdapBottom => "rdap-bottom", + ArchivedLinkRelation::RdapDown => "rdap-down", + ArchivedLinkRelation::RdapTop => "rdap-top", + ArchivedLinkRelation::RdapUp => "rdap-up", + ArchivedLinkRelation::Related => "related", + ArchivedLinkRelation::Restconf => "restconf", + ArchivedLinkRelation::Replies => "replies", + ArchivedLinkRelation::Ruleinput => "ruleinput", + ArchivedLinkRelation::Search => "search", + ArchivedLinkRelation::Section => "section", + ArchivedLinkRelation::Self_ => "self", + ArchivedLinkRelation::Service => "service", + ArchivedLinkRelation::ServiceDesc => "service-desc", + ArchivedLinkRelation::ServiceDoc => "service-doc", + ArchivedLinkRelation::ServiceMeta => "service-meta", + ArchivedLinkRelation::SipTrunkingCapability => "sip-trunking-capability", + ArchivedLinkRelation::Sponsored => "sponsored", + ArchivedLinkRelation::Start => "start", + ArchivedLinkRelation::Status => "status", + ArchivedLinkRelation::Stylesheet => "stylesheet", + ArchivedLinkRelation::Subsection => "subsection", + ArchivedLinkRelation::SuccessorVersion => "successor-version", + ArchivedLinkRelation::Sunset => "sunset", + ArchivedLinkRelation::Tag => "tag", + ArchivedLinkRelation::TermsOfService => "terms-of-service", + ArchivedLinkRelation::Timegate => "timegate", + ArchivedLinkRelation::Timemap => "timemap", + ArchivedLinkRelation::Type => "type", + ArchivedLinkRelation::Ugc => "ugc", + ArchivedLinkRelation::Up => "up", + ArchivedLinkRelation::VersionHistory => "version-history", + ArchivedLinkRelation::Via => "via", + ArchivedLinkRelation::Webmention => "webmention", + ArchivedLinkRelation::WorkingCopy => "working-copy", + ArchivedLinkRelation::WorkingCopyOf => "working-copy-of", + } + } +} + +impl ArchivedICalendarRelated { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedICalendarRelated::Start => "START", + ArchivedICalendarRelated::End => "END", + } + } +} + +#[derive(Debug)] +pub(crate) enum ArchivedValueType { + Ical(ArchivedICalendarValueType), + CalendarScale, + Method, + Classification, + Status, + Transparency, + Action, + BusyType, + ParticipantType, + ResourceType, + Proximity, +} +impl ArchivedValueType { + pub fn unwrap_ical(self) -> ArchivedICalendarValueType { + match self { + ArchivedValueType::Ical(v) => v, + _ => ArchivedICalendarValueType::Text, + } + } +} diff --git a/vendor/calcard/src/icalendar/rkyv_utils.rs b/vendor/calcard/src/icalendar/rkyv_utils.rs new file mode 100644 index 00000000..504b6a53 --- /dev/null +++ b/vendor/calcard/src/icalendar/rkyv_utils.rs @@ -0,0 +1,433 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; +use crate::common::{ArchivedPartialDateTime, timezone::Tz}; +use chrono::DateTime; + +impl ArchivedICalendar { + pub fn uids(&self) -> impl Iterator { + self.components + .iter() + .filter_map(|component| component.uid()) + } + + pub fn size(&self) -> usize { + self.components + .iter() + .map(|component| component.size()) + .sum() + } +} + +impl ArchivedICalendarComponent { + pub fn uid(&self) -> Option<&str> { + self.property(&ICalendarProperty::Uid) + .and_then(|e| e.values.first()) + .and_then(|v| v.as_text()) + } + + pub fn property(&self, prop: &ICalendarProperty) -> Option<&ArchivedICalendarEntry> { + self.entries.iter().find(|entry| &entry.name == prop) + } + + pub fn properties<'x, 'y: 'x>( + &'x self, + prop: &'y ICalendarProperty, + ) -> impl Iterator + 'x { + self.entries.iter().filter(move |entry| &entry.name == prop) + } + + pub fn size(&self) -> usize { + self.entries.iter().map(|entry| entry.size()).sum() + } + + pub fn is_recurrent(&self) -> bool { + self.entries.iter().any(|entry| { + matches!( + entry.name, + ArchivedICalendarProperty::Rrule | ArchivedICalendarProperty::Rdate + ) + }) + } + + pub fn is_recurrence_override(&self) -> bool { + self.entries + .iter() + .any(|entry| matches!(entry.name, ArchivedICalendarProperty::RecurrenceId)) + } + + pub fn is_recurrent_or_override(&self) -> bool { + self.entries.iter().any(|entry| { + matches!( + entry.name, + ArchivedICalendarProperty::Rrule + | ArchivedICalendarProperty::Rdate + | ArchivedICalendarProperty::RecurrenceId + ) + }) + } + + pub fn status(&self) -> Option<&ArchivedICalendarStatus> { + self.entries + .iter() + .find_map(|entry| match (&entry.name, entry.values.first()) { + ( + ArchivedICalendarProperty::Status, + Some(ArchivedICalendarValue::Status(status)), + ) => Some(status), + _ => None, + }) + } + + pub fn transparency(&self) -> Option<&ArchivedICalendarTransparency> { + self.entries + .iter() + .find_map(|entry| match (&entry.name, entry.values.first()) { + ( + ArchivedICalendarProperty::Transp, + Some(ArchivedICalendarValue::Transparency(trans)), + ) => Some(trans), + _ => None, + }) + } +} + +impl ArchivedICalendarValue { + pub fn size(&self) -> usize { + match self { + ArchivedICalendarValue::Binary(value) => value.len(), + ArchivedICalendarValue::Text(value) => value.len(), + ArchivedICalendarValue::PartialDateTime(_) => std::mem::size_of::(), + ArchivedICalendarValue::RecurrenceRule(_) => { + std::mem::size_of::() + } + _ => std::mem::size_of::(), + } + } + + pub fn as_text(&self) -> Option<&str> { + match self { + ArchivedICalendarValue::Text(s) => Some(s.as_str()), + ArchivedICalendarValue::Uri(v) => v.as_str(), + ArchivedICalendarValue::CalendarScale(v) => Some(v.as_str()), + ArchivedICalendarValue::Method(v) => Some(v.as_str()), + ArchivedICalendarValue::Classification(v) => Some(v.as_str()), + ArchivedICalendarValue::Status(v) => Some(v.as_str()), + ArchivedICalendarValue::Transparency(v) => Some(v.as_str()), + ArchivedICalendarValue::Action(v) => Some(v.as_str()), + ArchivedICalendarValue::BusyType(v) => Some(v.as_str()), + ArchivedICalendarValue::ParticipantType(v) => Some(v.as_str()), + ArchivedICalendarValue::ResourceType(v) => Some(v.as_str()), + ArchivedICalendarValue::Proximity(v) => Some(v.as_str()), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + ArchivedICalendarValue::Integer(i) => Some(i.to_native()), + _ => None, + } + } + + pub fn as_float(&self) -> Option { + match self { + ArchivedICalendarValue::Float(f) => Some(f.to_native()), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option { + match self { + ArchivedICalendarValue::Boolean(b) => Some(*b), + _ => None, + } + } + + pub fn as_partial_date_time(&self) -> Option<&ArchivedPartialDateTime> { + match self { + ArchivedICalendarValue::PartialDateTime(dt) => Some(dt), + _ => None, + } + } + + pub fn as_binary(&self) -> Option<&[u8]> { + match self { + ArchivedICalendarValue::Binary(d) => Some(d.as_slice()), + _ => None, + } + } +} + +impl ArchivedICalendarParameterName { + pub fn as_str(&self) -> &str { + match self { + ArchivedICalendarParameterName::Altrep => "ALTREP", + ArchivedICalendarParameterName::Cn => "CN", + ArchivedICalendarParameterName::Cutype => "CUTYPE", + ArchivedICalendarParameterName::DelegatedFrom => "DELEGATED-FROM", + ArchivedICalendarParameterName::DelegatedTo => "DELEGATED-TO", + ArchivedICalendarParameterName::Dir => "DIR", + ArchivedICalendarParameterName::Fmttype => "FMTTYPE", + ArchivedICalendarParameterName::Fbtype => "FBTYPE", + ArchivedICalendarParameterName::Language => "LANGUAGE", + ArchivedICalendarParameterName::Member => "MEMBER", + ArchivedICalendarParameterName::Partstat => "PARTSTAT", + ArchivedICalendarParameterName::Range => "RANGE", + ArchivedICalendarParameterName::Related => "RELATED", + ArchivedICalendarParameterName::Reltype => "RELTYPE", + ArchivedICalendarParameterName::Role => "ROLE", + ArchivedICalendarParameterName::Rsvp => "RSVP", + ArchivedICalendarParameterName::ScheduleAgent => "SCHEDULE-AGENT", + ArchivedICalendarParameterName::ScheduleForceSend => "SCHEDULE-FORCE-SEND", + ArchivedICalendarParameterName::ScheduleStatus => "SCHEDULE-STATUS", + ArchivedICalendarParameterName::SentBy => "SENT-BY", + ArchivedICalendarParameterName::Tzid => "TZID", + ArchivedICalendarParameterName::Value => "VALUE", + ArchivedICalendarParameterName::Display => "DISPLAY", + ArchivedICalendarParameterName::Email => "EMAIL", + ArchivedICalendarParameterName::Feature => "FEATURE", + ArchivedICalendarParameterName::Label => "LABEL", + ArchivedICalendarParameterName::Size => "SIZE", + ArchivedICalendarParameterName::Filename => "FILENAME", + ArchivedICalendarParameterName::ManagedId => "MANAGED-ID", + ArchivedICalendarParameterName::Order => "ORDER", + ArchivedICalendarParameterName::Schema => "SCHEMA", + ArchivedICalendarParameterName::Derived => "DERIVED", + ArchivedICalendarParameterName::Gap => "GAP", + ArchivedICalendarParameterName::Linkrel => "LINKREL", + ArchivedICalendarParameterName::Jsptr => "JSPTR", + ArchivedICalendarParameterName::Jsid => "JSID", + ArchivedICalendarParameterName::Other(name) => name.as_str(), + } + } +} + +impl ArchivedICalendarEntry { + #[inline] + pub fn parameters( + &self, + prop: &ICalendarParameterName, + ) -> impl Iterator { + self.params.iter().filter_map(move |param| { + if ¶m.name == prop { + Some(¶m.value) + } else { + None + } + }) + } + + pub fn size(&self) -> usize { + self.values.iter().map(|value| value.size()).sum::() + + self + .params + .iter() + .map(|param| std::mem::size_of::() + param.value.size()) + .sum::() + + self.name.as_str().len() + } +} + +impl ArchivedICalendarParameterValue { + pub fn as_text(&self) -> Option<&str> { + match self { + ArchivedICalendarParameterValue::Text(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Bool(v) => Some(if *v { "TRUE" } else { "FALSE" }), + ArchivedICalendarParameterValue::Uri(uri) => uri.as_str(), + ArchivedICalendarParameterValue::Cutype(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Fbtype(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Partstat(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Related(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Reltype(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Role(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::ScheduleAgent(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::ScheduleForceSend(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Value(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Display(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Feature(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Linkrel(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Duration(_) + | ArchivedICalendarParameterValue::Integer(_) + | ArchivedICalendarParameterValue::Null => None, + } + } + + pub fn size(&self) -> usize { + match self { + ArchivedICalendarParameterValue::Text(v) => v.len(), + ArchivedICalendarParameterValue::Uri(v) => v.size(), + _ => std::mem::size_of::(), + } + } +} + +impl ArchivedUri { + pub fn as_str(&self) -> Option<&str> { + match self { + ArchivedUri::Data(data) => data.content_type.as_deref(), + ArchivedUri::Location(loc) => loc.as_str().into(), + } + } + + pub fn size(&self) -> usize { + match self { + ArchivedUri::Data(data) => { + data.data.len() + data.content_type.as_ref().map(|s| s.len()).unwrap_or(0) + } + ArchivedUri::Location(loc) => loc.len(), + } + } +} + +impl ArchivedICalendarPeriod { + pub fn time_range(&self, tz: Tz) -> Option<(DateTime, DateTime)> { + match self { + ArchivedICalendarPeriod::Range { start, end } => { + if let (Some(start), Some(end)) = ( + start + .to_date_time() + .and_then(|start| start.to_date_time_with_tz(tz)), + end.to_date_time() + .and_then(|end| end.to_date_time_with_tz(tz)), + ) { + Some((start, end)) + } else { + None + } + } + ArchivedICalendarPeriod::Duration { start, duration } => start + .to_date_time() + .and_then(|start| start.to_date_time_with_tz(tz)) + .zip(duration.to_time_delta()) + .and_then(|(start, duration)| { + start.checked_add_signed(duration).map(|end| (start, end)) + }), + } + } +} + +impl ArchivedPartialDateTime { + pub fn to_date_time_with_tz(&self, tz: Tz) -> Option> { + self.to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(tz)) + } + + #[inline(always)] + pub fn has_date(&self) -> bool { + self.year.is_some() && self.month.is_some() && self.day.is_some() + } + + #[inline(always)] + pub fn has_time(&self) -> bool { + self.hour.is_some() && self.minute.is_some() + } + + #[inline(always)] + pub fn has_zone(&self) -> bool { + self.tz_hour.is_some() + } + + #[inline(always)] + pub fn has_date_and_time(&self) -> bool { + self.has_date() && self.has_time() + } +} + +impl ArchivedICalendarDuration { + pub fn to_time_delta(&self) -> Option { + chrono::TimeDelta::new(self.as_seconds(), 0) + } + + pub fn as_seconds(&self) -> i64 { + let secs = self.seconds.to_native() as i64 + + self.minutes.to_native() as i64 * 60 + + self.hours.to_native() as i64 * 3600 + + self.days.to_native() as i64 * 86400 + + self.weeks.to_native() as i64 * 604800; + + if self.neg { -secs } else { secs } + } + + pub fn to_native(&self) -> ICalendarDuration { + ICalendarDuration { + neg: self.neg, + weeks: self.weeks.to_native(), + days: self.days.to_native(), + hours: self.hours.to_native(), + minutes: self.minutes.to_native(), + seconds: self.seconds.to_native(), + } + } +} + +impl ArchivedICalendarComponentType { + pub fn has_time_ranges(&self) -> bool { + matches!( + self, + ArchivedICalendarComponentType::VEvent + | ArchivedICalendarComponentType::VTodo + | ArchivedICalendarComponentType::VJournal + | ArchivedICalendarComponentType::VFreebusy + ) + } + + pub fn is_scheduling_object(&self) -> bool { + matches!( + self, + ArchivedICalendarComponentType::VEvent + | ArchivedICalendarComponentType::VTodo + | ArchivedICalendarComponentType::VJournal + | ArchivedICalendarComponentType::VFreebusy + ) + } + + pub fn is_event(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VEvent) + } + + pub fn is_todo(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VTodo) + } + + pub fn is_event_or_todo(&self) -> bool { + matches!( + self, + ArchivedICalendarComponentType::VEvent | ArchivedICalendarComponentType::VTodo + ) + } + + pub fn is_journal(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VJournal) + } + + pub fn is_freebusy(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VFreebusy) + } + + pub fn is_location(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VLocation) + } + + pub fn is_alarm(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::VAlarm) + } + + pub fn is_participant(&self) -> bool { + matches!(self, ArchivedICalendarComponentType::Participant) + } +} + +impl ArchivedICalendarMonth { + pub fn is_leap(&self) -> bool { + self.0 < 0 + } + + pub fn month(&self) -> u8 { + self.0.unsigned_abs() + } +} diff --git a/vendor/calcard/src/icalendar/rkyv_writer.rs b/vendor/calcard/src/icalendar/rkyv_writer.rs new file mode 100644 index 00000000..b9375e47 --- /dev/null +++ b/vendor/calcard/src/icalendar/rkyv_writer.rs @@ -0,0 +1,519 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; +use crate::{ + common::{ + ArchivedPartialDateTime, CalendarScale, + writer::{FoldingWriter, LineWriter, write_bytes, write_param_value, write_text}, + }, + icalendar::{ + ValueSeparator, + writer::{write_component_begin, write_component_end}, + }, +}; +use std::{ + fmt::{Display, Write}, + slice::Iter, +}; + +impl ArchivedICalendar { + pub fn write_to(&self, out: &mut impl Write) -> std::fmt::Result { + let _v = [0.into()]; + let mut component_iter: Iter<'_, rkyv::primitive::ArchivedU32> = _v.iter(); + let mut component_stack = Vec::with_capacity(4); + + loop { + if let Some(component_id) = component_iter.next() { + let component = self + .components + .get(component_id.to_native() as usize) + .unwrap(); + write_component_begin(out, component.component_type.as_str())?; + + for entry in component.entries.iter() { + if !matches!( + entry.name, + ArchivedICalendarProperty::Begin | ArchivedICalendarProperty::End + ) { + entry.write_to(out, true)?; + } + } + + if !component.component_ids.is_empty() { + component_stack.push((component, component_iter)); + component_iter = component.component_ids.iter(); + } else { + write_component_end(out, component.component_type.as_str())?; + } + } else if let Some((component, iter)) = component_stack.pop() { + write_component_end(out, component.component_type.as_str())?; + component_iter = iter; + } else { + break; + } + } + + Ok(()) + } +} + +impl ArchivedICalendarEntry { + pub fn write_to(&self, out: &mut impl Write, with_value: bool) -> std::fmt::Result { + let mut folded = FoldingWriter::new(out); + let out = &mut folded; + + out.write_atomic(self.name.as_str())?; + + if matches!( + self.values.first().as_ref(), + Some(ArchivedICalendarValue::Binary(_)) + ) { + out.write_atomic(";ENCODING=BASE64")?; + } + + let mut types = None; + let mut last_param: Option<&ArchivedICalendarParameterName> = None; + + for param in self.params.iter() { + if last_param.is_some_and(|last_param| last_param == ¶m.name) { + out.write_atomic(",")?; + } else { + out.write_atomic(";")?; + out.write_atomic(param.name.as_str())?; + if !matches!(param.value, ArchivedICalendarParameterValue::Null) { + out.write_atomic("=")?; + } + last_param = Some(¶m.name); + } + + match ¶m.value { + ArchivedICalendarParameterValue::Text(v) => { + write_param_value(out, v)?; + } + ArchivedICalendarParameterValue::Integer(i) => { + write!(out, "{i}")?; + } + ArchivedICalendarParameterValue::Bool(v) => { + let v = if !matches!(param.name, ArchivedICalendarParameterName::Range) { + if *v { "TRUE" } else { "FALSE" } + } else { + "THISANDFUTURE" + }; + out.write_atomic(v)?; + } + ArchivedICalendarParameterValue::Uri(uri) => { + out.write_atomic("\"")?; + write_uri(out, uri, false)?; + out.write_atomic("\"")?; + } + ArchivedICalendarParameterValue::Cutype(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Fbtype(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Partstat(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Related(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Reltype(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Role(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::ScheduleAgent(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::ScheduleForceSend(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Value(v) => { + types = Some(v); + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Display(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Feature(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Duration(v) => { + write!(out, "{v}")?; + } + ArchivedICalendarParameterValue::Linkrel(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedICalendarParameterValue::Null => { + last_param = None; + } + } + } + + out.write_atomic(":")?; + + if with_value { + let (default_type, separator) = self.name.default_types(); + let separator = if !matches!(separator, ValueSeparator::Comma) { + ";" + } else { + "," + }; + let default_type = default_type.unwrap_ical(); + + for (pos, value) in self.values.iter().enumerate() { + if pos > 0 { + out.write_atomic(separator)?; + } + + let text = match value { + ArchivedICalendarValue::Binary(v) => { + write_bytes(out, v)?; + continue; + } + ArchivedICalendarValue::Boolean(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + continue; + } + ArchivedICalendarValue::Uri(v) => { + write_uri(out, v, true)?; + continue; + } + ArchivedICalendarValue::PartialDateTime(v) => { + v.format_as_ical(out, types.unwrap_or(&default_type))?; + continue; + } + ArchivedICalendarValue::Duration(v) => { + write!(out, "{}", v)?; + continue; + } + ArchivedICalendarValue::RecurrenceRule(v) => { + write!(out, "{}", v)?; + continue; + } + ArchivedICalendarValue::Period(v) => { + write!(out, "{}", v)?; + continue; + } + ArchivedICalendarValue::Float(v) => { + write!(out, "{v}")?; + continue; + } + ArchivedICalendarValue::Integer(v) => { + write!(out, "{v}")?; + continue; + } + ArchivedICalendarValue::Text(v) => { + let escape = !matches!( + types.unwrap_or(&default_type), + ArchivedICalendarValueType::Recur + ); + write_text(out, v, escape, escape)?; + continue; + } + ArchivedICalendarValue::CalendarScale(v) => v.as_str(), + ArchivedICalendarValue::Method(v) => v.as_str(), + ArchivedICalendarValue::Classification(v) => v.as_str(), + ArchivedICalendarValue::Status(v) => v.as_str(), + ArchivedICalendarValue::Transparency(v) => v.as_str(), + ArchivedICalendarValue::Action(v) => v.as_str(), + ArchivedICalendarValue::BusyType(v) => v.as_str(), + ArchivedICalendarValue::ParticipantType(v) => v.as_str(), + ArchivedICalendarValue::ResourceType(v) => v.as_str(), + ArchivedICalendarValue::Proximity(v) => v.as_str(), + }; + + out.write_atomic(text)?; + } + } + out.end_line() + } +} + +pub(crate) fn write_uri( + out: &mut FoldingWriter<'_, W>, + value: &ArchivedUri, + escape: bool, +) -> std::fmt::Result { + match value { + ArchivedUri::Data(v) => { + let media_type = v.content_type.as_deref().unwrap_or_default(); + out.write_str("data:")?; + out.write_str(media_type)?; + out.write_str(";")?; + if escape { + out.write_atomic("base64\\,")?; + } else { + out.write_atomic("base64,")?; + } + write_bytes(out, &v.data) + } + ArchivedUri::Location(v) => write_text(out, v, escape, escape), + } +} + +impl Display for ArchivedICalendarRecurrenceRule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FREQ={}", self.freq.as_str())?; + if let Some(until) = self.until.as_ref() { + write!(f, ";UNTIL=")?; + until.format_as_ical( + f, + if until.has_date_and_time() { + &ArchivedICalendarValueType::DateTime + } else { + &ArchivedICalendarValueType::Date + }, + )?; + } + if let Some(count) = self.count.as_ref().filter(|c| **c > 0) { + write!(f, ";COUNT={}", count)?; + } + if let Some(interval) = self.interval.as_ref() { + write!(f, ";INTERVAL={}", interval)?; + } + if !self.bysecond.is_empty() { + write!(f, ";BYSECOND=")?; + for (pos, item) in self.bysecond.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byminute.is_empty() { + write!(f, ";BYMINUTE=")?; + for (pos, item) in self.byminute.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byhour.is_empty() { + write!(f, ";BYHOUR=")?; + for (pos, item) in self.byhour.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byday.is_empty() { + write!(f, ";BYDAY=")?; + for (pos, item) in self.byday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bymonthday.is_empty() { + write!(f, ";BYMONTHDAY=")?; + for (pos, item) in self.bymonthday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byyearday.is_empty() { + write!(f, ";BYYEARDAY=")?; + for (pos, item) in self.byyearday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byweekno.is_empty() { + write!(f, ";BYWEEKNO=")?; + for (pos, item) in self.byweekno.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bymonth.is_empty() { + write!(f, ";BYMONTH=")?; + for (pos, item) in self.bymonth.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bysetpos.is_empty() { + write!(f, ";BYSETPOS=")?; + for (pos, item) in self.bysetpos.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if let Some(wkst) = self.wkst.as_ref() { + write!(f, ";WKST={}", wkst.as_str())?; + } + if let Some(rscale) = self.rscale.as_ref() { + write!(f, ";RSCALE={}", rscale.as_str())?; + } else if self.skip.is_some() { + write!(f, ";RSCALE={}", CalendarScale::Gregorian.as_str())?; + } + if let Some(skip) = self.skip.as_ref() { + write!(f, ";SKIP={}", skip.as_str())?; + } + + Ok(()) + } +} + +impl Display for ArchivedICalendarDay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(ordwk) = self.ordwk.as_ref() { + write!(f, "{}", ordwk)?; + } + write!(f, "{}", self.weekday.as_str()) + } +} + +impl Display for ArchivedICalendarMonth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.is_leap() { + write!(f, "{}", self.month()) + } else { + write!(f, "{}L", self.month()) + } + } +} + +impl Display for ArchivedICalendarPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArchivedICalendarPeriod::Range { start, end } => { + start.format_as_ical(f, &ArchivedICalendarValueType::DateTime)?; + write!(f, "/")?; + end.format_as_ical(f, &ArchivedICalendarValueType::DateTime) + } + ArchivedICalendarPeriod::Duration { start, duration } => { + start.format_as_ical(f, &ArchivedICalendarValueType::DateTime)?; + write!(f, "/{}", duration) + } + } + } +} + +impl Display for ArchivedICalendarDuration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.neg { + write!(f, "-")?; + } + write!(f, "P")?; + if self.weeks == 0 + && self.days == 0 + && self.hours == 0 + && self.minutes == 0 + && self.seconds == 0 + { + return write!(f, "T0S"); + } + if self.weeks != 0 { + write!(f, "{}W", self.weeks)?; + } + if self.days != 0 { + write!(f, "{}D", self.days)?; + } + if self.hours != 0 || self.minutes != 0 || self.seconds != 0 { + write!(f, "T")?; + if self.hours != 0 { + write!(f, "{}H", self.hours)?; + } + if self.minutes != 0 { + write!(f, "{}M", self.minutes)?; + } + if self.seconds != 0 { + write!(f, "{}S", self.seconds)?; + } + } + + Ok(()) + } +} + +impl ArchivedPartialDateTime { + pub fn format_as_ical( + &self, + out: &mut impl Write, + fmt: &ArchivedICalendarValueType, + ) -> std::fmt::Result { + if matches!( + fmt, + ArchivedICalendarValueType::Date | ArchivedICalendarValueType::DateTime + ) { + write!( + out, + "{:04}{:02}{:02}", + self.year + .as_ref() + .map(|n| n.to_native()) + .unwrap_or_default(), + self.month.as_ref().copied().unwrap_or_default(), + self.day.as_ref().copied().unwrap_or_default(), + )?; + } + + if matches!(fmt, ArchivedICalendarValueType::DateTime) { + write!(out, "T")?; + } + + if matches!( + fmt, + ArchivedICalendarValueType::DateTime | ArchivedICalendarValueType::Time + ) { + write!( + out, + "{:02}{:02}{:02}", + self.hour.as_ref().copied().unwrap_or_default(), + self.minute.as_ref().copied().unwrap_or_default(), + self.second.as_ref().copied().unwrap_or_default(), + )?; + + if matches!( + (self.tz_hour.as_ref(), self.tz_minute.as_ref()), + (Some(0), Some(0)) + ) { + write!(out, "Z")?; + } + } + + if matches!(fmt, ArchivedICalendarValueType::UtcOffset) { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + + write!( + out, + "{:02}{:02}", + self.tz_hour.as_ref().copied().unwrap_or_default(), + self.tz_minute.as_ref().copied().unwrap_or_default(), + )?; + } + + Ok(()) + } +} + +impl Display for ArchivedICalendar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.write_to(f) + } +} diff --git a/vendor/calcard/src/icalendar/timezone.rs b/vendor/calcard/src/icalendar/timezone.rs new file mode 100644 index 00000000..3b02b46e --- /dev/null +++ b/vendor/calcard/src/icalendar/timezone.rs @@ -0,0 +1,1221 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDay, ICalendarEntry, + ICalendarFrequency, ICalendarMonth, ICalendarPeriod, ICalendarProperty, + ICalendarRecurrenceRule, ICalendarValue, ICalendarWeekday, +}; +use crate::{ + common::{PartialDateTime, timezone::Tz}, + icalendar::ICalendarParameterName, +}; +use chrono::{Datelike, NaiveDate, NaiveDateTime, Offset, TimeZone, Timelike, Utc, Weekday}; +use chrono_tz::{OffsetComponents, OffsetName}; +use std::{collections::HashMap, ops::Range, str::FromStr}; + +pub struct TzResolver { + tzs: HashMap, + default: Tz, +} + +impl TzResolver +where + T: std::borrow::Borrow + std::hash::Hash + Eq, +{ + pub fn resolve(&self, tz_name: &str) -> Option { + self.tzs + .get(tz_name) + .copied() + .or_else(|| Tz::from_str(tz_name).ok()) + } + + pub fn resolve_or_default(&self, tz_name: Option<&str>) -> Tz { + tz_name + .and_then(|tz_name| { + self.tzs + .get(tz_name) + .copied() + .or_else(|| Tz::from_str(tz_name).ok()) + }) + .unwrap_or(self.default) + } + + pub fn with_default(mut self, default: impl Into) -> Self { + self.default = default.into(); + self + } +} + +impl ICalendar { + pub fn timezones(&self) -> impl Iterator { + self.components + .iter() + .filter(|comp| matches!(comp.component_type, ICalendarComponentType::VTimezone)) + } + + pub fn is_timezone(&self) -> bool { + self.timezones().count() == 1 + } + + pub fn build_tz_resolver(&self) -> TzResolver<&'_ str> { + TzResolver { + tzs: self.timezones().filter_map(|tz| tz.timezone()).collect(), + default: Tz::Floating, + } + } + + pub fn build_owned_tz_resolver(&self) -> TzResolver { + TzResolver { + tzs: self + .timezones() + .filter_map(|tz| tz.timezone()) + .map(|(name, tz)| (name.to_string(), tz)) + .collect(), + default: Tz::Floating, + } + } +} + +impl ICalendarComponent { + pub fn timezone(&self) -> Option<(&str, Tz)> { + let mut tz_name = None; + let mut tz_id = None; + let mut tz_lic = None; + let mut tz_cdo_id = None; + + for entry in &self.entries { + match (&entry.name, entry.values.first()) { + (ICalendarProperty::Tzid, Some(ICalendarValue::Text(id))) => { + tz_id = Tz::from_str(id).ok(); + tz_name = Some(id.as_str()); + } + (ICalendarProperty::Other(value), Some(ICalendarValue::Text(id))) => { + hashify::fnc_map!(value.as_bytes(), + "X-LIC-LOCATION" => { + tz_lic = Tz::from_str(id.strip_prefix("SystemV/").unwrap_or(id.as_str())).ok(); + }, + "X-MICROSOFT-CDO-TZID" => { + tz_cdo_id = Tz::from_ms_cdo_zone_id(id); + }, + _ => {} + ); + } + _ => (), + } + } + + tz_name.zip(tz_id.or(tz_lic).or(tz_cdo_id)) + } +} + +impl ICalendarEntry { + pub fn tz_id(&self) -> Option<&str> { + self.parameters(&ICalendarParameterName::Tzid) + .find_map(|v| v.as_text()) + } +} + +const TZ_MIN_YEAR: i32 = 1900; +const TZ_MAX_FUTURE_YEARS: i32 = 100; +const TZ_RANGE_MARGIN: i64 = 86400; +const TZ_SCAN_STEP: i64 = 3 * 86400; +const TZ_RULE_ACTIVE_SECONDS: i64 = 366 * 86400; + +impl ICalendar { + pub fn add_timezone(&mut self, tz_id: &str, from: i64, to: i64) -> Option { + if self + .components + .first() + .is_none_or(|root| root.component_type != ICalendarComponentType::VCalendar) + { + return None; + } + + let observances = match Tz::from_str(tz_id).ok()? { + Tz::Tz(tz) => build_observances(tz, from, to), + Tz::Fixed(offset) => { + let offset = round_to_minute(offset.local_minus_utc()); + vec![TzObservance { + at: from, + from_offset: offset, + to_offset: offset, + is_dst: false, + name: None, + rrule: None, + }] + } + Tz::Floating => return None, + }; + + let insert_at = self.components[0] + .component_ids + .iter() + .position(|component_id| { + self.components + .get(*component_id as usize) + .is_none_or(|comp| comp.component_type != ICalendarComponentType::VTimezone) + }) + .unwrap_or(self.components[0].component_ids.len()); + + let tz_component_id = self.components.len() as u32; + self.components.reserve(observances.len() + 1); + self.components.push(ICalendarComponent { + component_type: ICalendarComponentType::VTimezone, + entries: vec![ + ICalendarEntry::new(ICalendarProperty::Tzid).with_value(tz_id.to_string()), + ], + component_ids: Vec::with_capacity(observances.len()), + }); + + let first_component_id = self.components.len() as u32; + for observance in observances { + self.components.push(observance.into_component()); + } + let last_component_id = self.components.len() as u32; + self.components[tz_component_id as usize] + .component_ids + .extend(first_component_id..last_component_id); + + self.components[0] + .component_ids + .insert(insert_at, tz_component_id); + + Some(tz_component_id) + } + + pub fn add_missing_timezones(&mut self) -> usize { + let mut defined: Vec<&str> = Vec::new(); + for component in self.timezones() { + for entry in &component.entries { + if entry.name == ICalendarProperty::Tzid + && let Some(ICalendarValue::Text(tz_id)) = entry.values.first() + { + defined.push(tz_id.as_str()); + } + } + } + + let mut referenced: Vec = Vec::new(); + let mut range: Option<(i64, i64)> = None; + let mut recurrence_span = 0; + let mut is_unbounded = false; + + for component in &self.components { + if matches!( + component.component_type, + ICalendarComponentType::VTimezone + | ICalendarComponentType::Standard + | ICalendarComponentType::Daylight + ) { + continue; + } + + for entry in &component.entries { + for param in &entry.params { + if param.name == ICalendarParameterName::Tzid + && let Some(tz_id) = param.value.as_text() + && !defined.contains(&tz_id) + && !referenced.iter().any(|id| id == tz_id) + { + referenced.push(tz_id.to_string()); + } + } + + match &entry.name { + ICalendarProperty::Rrule => { + for value in &entry.values { + if let ICalendarValue::RecurrenceRule(rule) = value { + match (&rule.until, &rule.count) { + (Some(until), _) => expand_range(&mut range, until), + (None, Some(count)) => { + recurrence_span = recurrence_span.max(recurrence_span_of( + &rule.freq, + *count, + rule.interval, + )); + } + (None, None) => is_unbounded = true, + } + } + } + } + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Due + | ICalendarProperty::RecurrenceId + | ICalendarProperty::Exdate + | ICalendarProperty::Rdate => { + for value in &entry.values { + match value { + ICalendarValue::PartialDateTime(dt) => { + expand_range(&mut range, dt); + } + ICalendarValue::Period(ICalendarPeriod::Range { start, end }) => { + expand_range(&mut range, start); + expand_range(&mut range, end); + } + ICalendarValue::Period(ICalendarPeriod::Duration { + start, .. + }) => { + expand_range(&mut range, start); + } + _ => (), + } + } + } + _ => (), + } + } + } + + if referenced.is_empty() { + return 0; + } + + let now = Utc::now().timestamp(); + let (min, max) = range.unwrap_or((now, now)); + let max = max.saturating_add(recurrence_span); + let current_year = year_of(now); + let max_year = current_year.saturating_add(TZ_MAX_FUTURE_YEARS); + let from_year = year_of(min).clamp(TZ_MIN_YEAR, max_year); + let to_year = if is_unbounded { + year_of(max).max(current_year) + } else { + year_of(max) + } + .saturating_add(2) + .clamp(from_year.saturating_add(1), max_year.saturating_add(2)); + let from = start_of_year(from_year).saturating_sub(TZ_RANGE_MARGIN); + let to = start_of_year(to_year); + + let mut added = 0; + for tz_id in referenced { + if self.add_timezone(&tz_id, from, to).is_some() { + added += 1; + } + } + added + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TzState { + offset: i32, + is_dst: bool, +} + +#[derive(PartialEq, Eq)] +struct TzRuleKey { + month: u8, + ordwk: i16, + weekday: ICalendarWeekday, + hour: u8, + minute: u8, + second: u8, + from_offset: i32, + to_offset: i32, +} + +struct TzObservance { + at: i64, + from_offset: i32, + to_offset: i32, + is_dst: bool, + name: Option, + rrule: Option, +} + +impl TzObservance { + fn onset(&self) -> NaiveDateTime { + naive_utc(self.at.saturating_add(self.from_offset as i64)) + } + + fn rule_key(&self) -> Option<(i32, TzRuleKey)> { + let onset = self.onset(); + let day = onset.day(); + let ordwk = if day.saturating_add(7) > days_in_month(onset.year(), onset.month()) { + -1 + } else { + ((day - 1) / 7 + 1) as i16 + }; + + Some(( + onset.year(), + TzRuleKey { + month: u8::try_from(onset.month()).ok()?, + ordwk, + weekday: weekday_of(onset.weekday()), + hour: u8::try_from(onset.hour()).ok()?, + minute: u8::try_from(onset.minute()).ok()?, + second: u8::try_from(onset.second()).ok()?, + from_offset: self.from_offset, + to_offset: self.to_offset, + }, + )) + } + + fn into_component(self) -> ICalendarComponent { + let mut component = ICalendarComponent::new(if self.is_dst { + ICalendarComponentType::Daylight + } else { + ICalendarComponentType::Standard + }); + + component + .entries + .push(ICalendarEntry::new(ICalendarProperty::Dtstart).with_value( + PartialDateTime::from_naive_timestamp(self.onset().and_utc().timestamp()), + )); + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Tzoffsetfrom) + .with_value(offset_to_partial(self.from_offset)), + ); + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Tzoffsetto) + .with_value(offset_to_partial(self.to_offset)), + ); + if let Some(name) = self.name { + component + .entries + .push(ICalendarEntry::new(ICalendarProperty::Tzname).with_value(name)); + } + if let Some(rrule) = self.rrule { + component + .entries + .push(ICalendarEntry::new(ICalendarProperty::Rrule).with_value(rrule)); + } + + component + } +} + +fn build_observances(tz: chrono_tz::Tz, from: i64, to: i64) -> Vec { + let (initial, initial_name) = tz_observance_at(tz, from); + let mut observances = Vec::with_capacity(estimated_observances(from, to)); + observances.push(TzObservance { + at: from, + from_offset: round_to_minute(initial.offset), + to_offset: round_to_minute(initial.offset), + is_dst: initial.is_dst, + name: initial_name, + rrule: None, + }); + + let mut previous_at = from; + let mut previous = initial; + let mut at = from; + + while at < to { + at = at.saturating_add(TZ_SCAN_STEP).min(to); + let state = tz_state_at(tz, at); + if state != previous { + let onset = find_transition(tz, previous_at, at, previous); + observances.push(TzObservance { + at: onset, + from_offset: round_to_minute(previous.offset), + to_offset: round_to_minute(state.offset), + is_dst: state.is_dst, + name: tz_name_at(tz, onset), + rrule: None, + }); + previous = state; + } + previous_at = at; + } + + label_observances(&mut observances); + + collapse_observances(observances, to) +} + +fn label_observances(observances: &mut [TzObservance]) { + let mut daylight_offset = None; + let mut standard_offset = None; + + for observance in observances.iter() { + let slot = if observance.is_dst { + &mut daylight_offset + } else { + &mut standard_offset + }; + *slot = Some(slot.map_or(observance.to_offset, |offset: i32| { + offset.max(observance.to_offset) + })); + } + + if let (Some(daylight_offset), Some(standard_offset)) = (daylight_offset, standard_offset) + && daylight_offset < standard_offset + { + for observance in observances.iter_mut() { + observance.is_dst = !observance.is_dst; + } + } +} + +fn collapse_observances(mut observances: Vec, to: i64) -> Vec { + let mut discard = vec![false; observances.len()]; + let mut unbounded: [Option<(usize, i64)>; 2] = [None, None]; + let mut trailing: [Option<(usize, u8, i16, ICalendarWeekday)>; 2] = [None, None]; + + let mut onsets: Vec<(usize, i32, TzRuleKey)> = Vec::new(); + let mut runs: Vec> = Vec::new(); + + for is_dst in [false, true] { + onsets.clear(); + runs.clear(); + + for (index, observance) in observances.iter().enumerate() { + if index == 0 || observance.is_dst != is_dst { + continue; + } + let Some((year, key)) = observance.rule_key() else { + continue; + }; + let extends = onsets + .last() + .is_some_and(|(last_index, last_year, last_key)| { + last_year.saturating_add(1) == year + && last_key == &key + && observances[*last_index].name == observance.name + }); + + onsets.push((index, year, key)); + match runs.last_mut() { + Some(run) if extends => run.end = onsets.len(), + _ => runs.push(onsets.len() - 1..onsets.len()), + } + } + + for (position, run) in runs.iter().enumerate() { + let (Some((first, _, key)), Some((last, _, _))) = + (onsets.get(run.start), onsets.get(run.end - 1)) + else { + continue; + }; + if run.len() < 2 { + continue; + } + + let last_at = observances[*last].at; + let is_expired = last_at.saturating_add(TZ_RULE_ACTIVE_SECONDS) < to; + let is_final = position + 1 == runs.len() && !is_expired; + observances[*first].rrule = Some(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + until: (!is_final).then(|| PartialDateTime::from_utc_timestamp(last_at)), + bymonth: vec![ICalendarMonth::new(key.month, false)], + byday: vec![ICalendarDay { + ordwk: Some(key.ordwk), + weekday: key.weekday, + }], + ..Default::default() + }); + if is_final { + unbounded[usize::from(is_dst)] = Some((*first, last_at)); + } + + for (index, _, _) in &onsets[run.start + 1..run.end] { + discard[*index] = true; + } + } + + if let Some(run) = runs.last() + && run.len() == 1 + && let Some((index, _, key)) = onsets.get(run.start) + { + trailing[usize::from(is_dst)] = Some((*index, key.month, key.ordwk, key.weekday)); + } + } + + for is_dst in [false, true] { + let class = usize::from(is_dst); + let (None, Some((other_index, other_at))) = + (unbounded[class], unbounded[usize::from(!is_dst)]) + else { + continue; + }; + + match trailing[class] { + Some((index, month, ordwk, weekday)) + if observances[index].at.saturating_add(TZ_RULE_ACTIVE_SECONDS) >= to => + { + observances[index].rrule = Some(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Yearly, + bymonth: vec![ICalendarMonth::new(month, false)], + byday: vec![ICalendarDay { + ordwk: Some(ordwk), + weekday, + }], + ..Default::default() + }); + unbounded[class] = Some((index, observances[index].at)); + } + _ => { + if let Some(rrule) = observances[other_index].rrule.as_mut() { + rrule.until = Some(PartialDateTime::from_utc_timestamp(other_at)); + unbounded[usize::from(!is_dst)] = None; + } + } + } + } + + let mut index = 0; + observances.retain(|_| { + let keep = !discard[index]; + index += 1; + keep + }); + + observances +} + +fn find_transition(tz: chrono_tz::Tz, mut low: i64, mut high: i64, low_state: TzState) -> i64 { + while high - low > 1 { + let middle = low + (high - low) / 2; + if tz_state_at(tz, middle) == low_state { + low = middle; + } else { + high = middle; + } + } + high +} + +fn tz_state_at(tz: chrono_tz::Tz, timestamp: i64) -> TzState { + let offset = tz.offset_from_utc_datetime(&naive_utc(timestamp)); + + TzState { + offset: offset.fix().local_minus_utc(), + is_dst: offset.dst_offset().num_seconds() != 0, + } +} + +fn tz_observance_at(tz: chrono_tz::Tz, timestamp: i64) -> (TzState, Option) { + let offset = tz.offset_from_utc_datetime(&naive_utc(timestamp)); + + ( + TzState { + offset: offset.fix().local_minus_utc(), + is_dst: offset.dst_offset().num_seconds() != 0, + }, + offset.abbreviation().map(|name| name.to_string()), + ) +} + +fn tz_name_at(tz: chrono_tz::Tz, timestamp: i64) -> Option { + tz.offset_from_utc_datetime(&naive_utc(timestamp)) + .abbreviation() + .map(|name| name.to_string()) +} + +fn estimated_observances(from: i64, to: i64) -> usize { + let years = to.saturating_sub(from) / TZ_RULE_ACTIVE_SECONDS; + + (years.clamp(0, 1024) as usize) * 2 + 2 +} + +fn round_to_minute(offset: i32) -> i32 { + let minutes = (offset.unsigned_abs() + 30) / 60 * 60; + + if offset < 0 { + -(minutes as i32) + } else { + minutes as i32 + } +} + +fn offset_to_partial(offset: i32) -> PartialDateTime { + let seconds = offset.unsigned_abs(); + + PartialDateTime { + tz_hour: u8::try_from(seconds / 3600).ok(), + tz_minute: u8::try_from((seconds % 3600) / 60).ok(), + tz_minus: offset < 0, + ..Default::default() + } +} + +fn recurrence_span_of(freq: &ICalendarFrequency, count: u32, interval: Option) -> i64 { + let unit = match freq { + ICalendarFrequency::Yearly => 366 * 86400, + ICalendarFrequency::Monthly => 31 * 86400, + ICalendarFrequency::Weekly => 7 * 86400, + ICalendarFrequency::Daily => 86400, + ICalendarFrequency::Hourly => 3600, + ICalendarFrequency::Minutely => 60, + ICalendarFrequency::Secondly => 1, + }; + + i64::from(count) + .saturating_mul(i64::from(interval.unwrap_or(1))) + .saturating_mul(unit) +} + +fn expand_range(range: &mut Option<(i64, i64)>, value: &PartialDateTime) { + let Some(timestamp) = value.to_date_time().map(|result| { + result.date_time.and_utc().timestamp() + - result + .offset + .map_or(0, |offset| offset.local_minus_utc() as i64) + }) else { + return; + }; + + match range { + Some((min, max)) => { + *min = (*min).min(timestamp); + *max = (*max).max(timestamp); + } + None => { + *range = Some((timestamp, timestamp)); + } + } +} + +fn naive_utc(timestamp: i64) -> NaiveDateTime { + chrono::DateTime::from_timestamp(timestamp, 0) + .unwrap_or_default() + .naive_utc() +} + +fn year_of(timestamp: i64) -> i32 { + naive_utc(timestamp).year() +} + +fn start_of_year(year: i32) -> i64 { + NaiveDate::from_ymd_opt(year, 1, 1) + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .map_or(0, |date_time| date_time.and_utc().timestamp()) +} + +fn days_in_month(year: i32, month: u32) -> u32 { + let (year, month) = if month == 12 { + (year.saturating_add(1), 1) + } else { + (year, month + 1) + }; + + NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|date| date.pred_opt()) + .map_or(31, |date| date.day()) +} + +fn weekday_of(weekday: Weekday) -> ICalendarWeekday { + match weekday { + Weekday::Mon => ICalendarWeekday::Monday, + Weekday::Tue => ICalendarWeekday::Tuesday, + Weekday::Wed => ICalendarWeekday::Wednesday, + Weekday::Thu => ICalendarWeekday::Thursday, + Weekday::Fri => ICalendarWeekday::Friday, + Weekday::Sat => ICalendarWeekday::Saturday, + Weekday::Sun => ICalendarWeekday::Sunday, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::icalendar::{ICalendar, ICalendarComponent, ICalendarComponentType}; + + const ORACLE_STEP: i64 = 3600; + const HORIZON_YEARS: i32 = 20; + const MAX_DIVERGENCE: f64 = 0.15; + + fn add_timezones(ical: &str) -> String { + let mut ical = ICalendar::parse(ical).expect("failed to parse iCalendar object"); + ical.add_missing_timezones(); + let result = ical.to_string(); + + let reparsed = ICalendar::parse(&result).expect("generated object failed to re-parse"); + assert_eq!( + reparsed.to_string(), + result, + "generated object is not stable" + ); + + result.replace("\r\n", "\n") + } + + fn event(properties: &str) -> String { + format!( + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:test\r\n{properties}END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + } + + #[test] + fn test_add_missing_timezones() { + for (properties, expected) in [ + ( + "DTSTART;TZID=Europe/Berlin:20260822T163000\r\nDTEND;TZID=Europe/Berlin:20260822T173000\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:Europe/Berlin\n", + "BEGIN:STANDARD\n", + "DTSTART:20251231T010000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nTZNAME:CET\n", + "END:STANDARD\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20260329T020000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nTZNAME:CEST\n", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\n", + "END:DAYLIGHT\n", + "BEGIN:STANDARD\n", + "DTSTART:20261025T030000\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nTZNAME:CET\n", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n", + "END:STANDARD\n", + "END:VTIMEZONE\n", + ), + ), + ( + "DTSTART;TZID=America/New_York:20260822T163000\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:America/New_York\n", + "BEGIN:STANDARD\n", + "DTSTART:20251230T190000\nTZOFFSETFROM:-0500\nTZOFFSETTO:-0500\nTZNAME:EST\n", + "END:STANDARD\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20260308T020000\nTZOFFSETFROM:-0500\nTZOFFSETTO:-0400\nTZNAME:EDT\n", + "RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3\n", + "END:DAYLIGHT\n", + "BEGIN:STANDARD\n", + "DTSTART:20261101T020000\nTZOFFSETFROM:-0400\nTZOFFSETTO:-0500\nTZNAME:EST\n", + "RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11\n", + "END:STANDARD\n", + "END:VTIMEZONE\n", + ), + ), + ( + "DTSTART;TZID=Australia/Sydney:20260822T163000\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:Australia/Sydney\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20251231T110000\nTZOFFSETFROM:+1100\nTZOFFSETTO:+1100\nTZNAME:AEDT\n", + "END:DAYLIGHT\n", + "BEGIN:STANDARD\n", + "DTSTART:20260405T030000\nTZOFFSETFROM:+1100\nTZOFFSETTO:+1000\nTZNAME:AEST\n", + "RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n", + "END:STANDARD\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20261004T020000\nTZOFFSETFROM:+1000\nTZOFFSETTO:+1100\nTZNAME:AEDT\n", + "RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=10\n", + "END:DAYLIGHT\n", + "END:VTIMEZONE\n", + ), + ), + ( + "DTSTART;TZID=Asia/Kolkata:20260822T163000\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:Asia/Kolkata\n", + "BEGIN:STANDARD\n", + "DTSTART:20251231T053000\nTZOFFSETFROM:+0530\nTZOFFSETTO:+0530\nTZNAME:IST\n", + "END:STANDARD\n", + "END:VTIMEZONE\n", + ), + ), + ( + "DTSTART;TZID=America/Sao_Paulo:20180301T163000\r\nRRULE:FREQ=MONTHLY;UNTIL=20190401T000000Z\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:America/Sao_Paulo\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20171230T220000\nTZOFFSETFROM:-0200\nTZOFFSETTO:-0200\n", + "END:DAYLIGHT\n", + "BEGIN:STANDARD\n", + "DTSTART:20180218T000000\nTZOFFSETFROM:-0200\nTZOFFSETTO:-0300\n", + "RRULE:FREQ=YEARLY;UNTIL=20190217T020000Z;BYDAY=3SU;BYMONTH=2\n", + "END:STANDARD\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20181104T000000\nTZOFFSETFROM:-0300\nTZOFFSETTO:-0200\n", + "END:DAYLIGHT\n", + "END:VTIMEZONE\n", + ), + ), + ( + // Negative daylight saving: the lower offset is the STANDARD observance, + // matching what other implementations publish for Europe/Dublin + "DTSTART;TZID=Europe/Dublin:20260822T163000\r\n", + concat!( + "BEGIN:VTIMEZONE\n", + "TZID:Europe/Dublin\n", + "BEGIN:STANDARD\n", + "DTSTART:20251231T000000\nTZOFFSETFROM:+0000\nTZOFFSETTO:+0000\nTZNAME:GMT\n", + "END:STANDARD\n", + "BEGIN:DAYLIGHT\n", + "DTSTART:20260329T010000\nTZOFFSETFROM:+0000\nTZOFFSETTO:+0100\nTZNAME:IST\n", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\n", + "END:DAYLIGHT\n", + "BEGIN:STANDARD\n", + "DTSTART:20261025T020000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0000\nTZNAME:GMT\n", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n", + "END:STANDARD\n", + "END:VTIMEZONE\n", + ), + ), + ] { + let result = add_timezones(&event(properties)); + assert!( + result.contains(expected), + "expected:\n{expected}\ngot:\n{result}" + ); + assert!( + result.contains("BEGIN:VTIMEZONE"), + "no definition generated:\n{result}" + ); + assert!( + result.find("BEGIN:VTIMEZONE") < result.find("BEGIN:VEVENT"), + "VTIMEZONE must precede VEVENT:\n{result}" + ); + } + } + + #[test] + fn test_add_missing_timezones_multiple() { + // One definition per referenced identifier, all of them ahead of the event + let result = add_timezones(&event(concat!( + "DTSTART;TZID=Europe/Berlin:20260822T163000\r\n", + "DTEND;TZID=America/New_York:20260822T173000\r\n", + ))); + assert!(result.contains("TZID:Europe/Berlin"), "{result}"); + assert!(result.contains("TZID:America/New_York"), "{result}"); + assert_eq!(result.matches("BEGIN:VTIMEZONE").count(), 2, "{result}"); + assert!( + result.find("BEGIN:VEVENT") > result.rfind("END:VTIMEZONE"), + "every VTIMEZONE must precede VEVENT:\n{result}" + ); + + // A definition added next to an existing one keeps both ahead of the event + let result = add_timezones(concat!( + "BEGIN:VCALENDAR\r\n", + "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n", + "BEGIN:STANDARD\r\nDTSTART:19961027T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n", + "END:STANDARD\r\nEND:VTIMEZONE\r\n", + "BEGIN:VEVENT\r\nUID:test\r\n", + "DTSTART;TZID=Europe/Berlin:20260822T163000\r\n", + "DTEND;TZID=Asia/Kolkata:20260822T173000\r\n", + "END:VEVENT\r\nEND:VCALENDAR\r\n" + )); + assert_eq!(result.matches("BEGIN:VTIMEZONE").count(), 2, "{result}"); + assert!( + result.find("BEGIN:VEVENT") > result.rfind("END:VTIMEZONE"), + "every VTIMEZONE must precede VEVENT:\n{result}" + ); + + // Windows style identifiers resolve and are preserved verbatim + for tz_id in [ + "Pacific Standard Time", + "(UTC-08:00) Pacific Time (US & Canada)", + ] { + let result = add_timezones(&event(&format!( + "DTSTART;TZID=\"{tz_id}\":20260822T163000\r\n" + ))); + assert!(result.contains(&format!("TZID:{tz_id}\n")), "{result}"); + assert!(result.contains("TZOFFSETTO:-0700"), "{result}"); + } + + // Fixed offset identifiers yield a single observance + let result = add_timezones(&event("DTSTART;TZID=Etc/GMT+5:20260822T163000\r\n")); + assert!( + result.contains("TZOFFSETFROM:-0500\nTZOFFSETTO:-0500"), + "{result}" + ); + assert_eq!(result.matches("BEGIN:STANDARD").count(), 1, "{result}"); + } + + #[test] + fn test_add_missing_timezones_noop() { + // Time zones that are already defined are left untouched + let defined = concat!( + "BEGIN:VCALENDAR\r\n", + "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n", + "BEGIN:STANDARD\r\nDTSTART:19961027T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n", + "END:STANDARD\r\nEND:VTIMEZONE\r\n", + "BEGIN:VEVENT\r\nUID:test\r\nDTSTART;TZID=Europe/Berlin:20260822T163000\r\n", + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ); + let mut ical = ICalendar::parse(defined).unwrap(); + assert_eq!(ical.add_missing_timezones(), 0); + assert_eq!(ical.timezones().count(), 1); + + // Unresolvable time zone identifiers are skipped + let mut ical = + ICalendar::parse(event("DTSTART;TZID=Nowhere/Atlantis:20260822T163000\r\n")).unwrap(); + assert_eq!(ical.add_missing_timezones(), 0); + assert_eq!(ical.timezones().count(), 0); + + // Objects without TZID references need no definitions + let mut ical = ICalendar::parse(event("DTSTART:20260822T163000Z\r\n")).unwrap(); + assert_eq!(ical.add_missing_timezones(), 0); + assert_eq!(ical.timezones().count(), 0); + + // A VTIMEZONE cannot be nested within another component + let mut ical = ICalendar { + components: vec![ICalendarComponent::new(ICalendarComponentType::VEvent)], + }; + assert_eq!(ical.add_timezone("Europe/Berlin", 0, TZ_RANGE_MARGIN), None); + assert_eq!( + ICalendar::default().add_timezone("Europe/Berlin", 0, TZ_RANGE_MARGIN), + None + ); + } + + #[test] + fn test_add_missing_timezones_covers_recurrence_range() { + // The window must reach the last instance of a counted recurrence + let mut ical = ICalendar::parse(event(concat!( + "DTSTART;TZID=Europe/Berlin:20260822T163000\r\n", + "RRULE:FREQ=YEARLY;COUNT=10\r\n" + ))) + .unwrap(); + assert_eq!(ical.add_missing_timezones(), 1); + assert!( + ical.to_string() + .contains("RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n"), + "{ical}" + ); + + let tz_component_id = ical + .components + .iter() + .position(|comp| comp.component_type == ICalendarComponentType::VTimezone) + .expect("no definition generated") as u32; + let onsets = implied_onsets(&ical, tz_component_id); + let last = onsets.last().expect("no onsets").0; + assert!( + last >= start_of_year(2035), + "definition ends at {last}, before the last instance" + ); + } + + #[test] + fn test_generated_definitions_match_tzdb() { + for (tz_id, from_year, to_year) in [ + ("Europe/Berlin", 2024, 2028), + ("Europe/Berlin", 1975, 2000), + ("Europe/Dublin", 2024, 2028), + ("America/New_York", 2024, 2028), + ("America/New_York", 1965, 1990), + ("Australia/Sydney", 2024, 2028), + ("Pacific/Chatham", 2024, 2028), + ("Australia/Lord_Howe", 2024, 2028), + ("America/Santiago", 2015, 2025), + ("America/Sao_Paulo", 2015, 2025), + ("Asia/Tehran", 2015, 2025), + ("Asia/Gaza", 2024, 2028), + ("Africa/Casablanca", 2018, 2025), + ("Africa/Monrovia", 1970, 1973), + ("Asia/Kolkata", 2024, 2028), + ("UTC", 2024, 2028), + ] { + let (from, to) = (start_of_year(from_year), start_of_year(to_year)); + let (tz, onsets) = generate(tz_id, from, to); + + // Inside the covered range the definition must reproduce the database exactly + let mut at = from; + while at < to { + let implied = offset_at(&onsets, at) + .unwrap_or_else(|| panic!("{tz_id}: no observance covers {at}")); + let actual = tz_state_at(tz, at).offset; + assert!( + implied.abs_diff(actual) <= 30, + "{tz_id}: offset {implied} does not match {actual} at {at}" + ); + at += ORACLE_STEP; + } + } + } + + #[test] + fn test_generated_definitions_do_not_drift() { + // Rules left unbounded are an approximation of the future, but they must never + // strand the zone on the wrong side of a daylight saving transition + for (tz_id, from_year, to_year) in [ + ("Europe/Berlin", 2024, 2028), + ("Europe/Dublin", 2024, 2028), + ("America/New_York", 2024, 2028), + ("Australia/Sydney", 2024, 2028), + ("Australia/Lord_Howe", 2024, 2028), + ("Asia/Gaza", 2024, 2028), + ("Asia/Hebron", 2024, 2028), + ("Asia/Jerusalem", 2024, 2028), + ("America/Santiago", 2024, 2028), + ("America/Nuuk", 2024, 2028), + ("Africa/Cairo", 2024, 2028), + ("Africa/Casablanca", 2024, 2028), + ("America/Sao_Paulo", 2024, 2028), + ("Asia/Kolkata", 2024, 2028), + ] { + let (from, to) = (start_of_year(from_year), start_of_year(to_year)); + let (tz, onsets) = generate(tz_id, from, to); + + let horizon = start_of_year(to_year + HORIZON_YEARS); + let (mut diverged, mut samples) = (0u32, 0u32); + let mut at = to; + while at < horizon { + if offset_at(&onsets, at) != Some(tz_state_at(tz, at).offset) { + diverged += 1; + } + samples += 1; + at += ORACLE_STEP; + } + + let ratio = f64::from(diverged) / f64::from(samples); + assert!( + ratio < MAX_DIVERGENCE, + "{tz_id}: definition drifts for {:.1}% of the {HORIZON_YEARS} years after the covered range", + ratio * 100.0 + ); + } + } + + fn generate(tz_id: &str, from: i64, to: i64) -> (chrono_tz::Tz, Vec<(i64, i32)>) { + let Ok(Tz::Tz(tz)) = Tz::from_str(tz_id) else { + panic!("{tz_id} did not resolve to an IANA time zone"); + }; + + let mut ical = ICalendar { + components: vec![ICalendarComponent::new(ICalendarComponentType::VCalendar)], + }; + let tz_component_id = ical + .add_timezone(tz_id, from, to) + .unwrap_or_else(|| panic!("no definition generated for {tz_id}")); + + (tz, implied_onsets(&ical, tz_component_id)) + } + + /// Expands the definition into the onsets it describes, exactly as a client would, + /// without reusing anything the generator relies on to find transitions. + fn implied_onsets(ical: &ICalendar, tz_component_id: u32) -> Vec<(i64, i32)> { + let mut onsets = Vec::new(); + let timezone = &ical.components[tz_component_id as usize]; + assert_eq!(timezone.component_type, ICalendarComponentType::VTimezone); + + for component_id in &timezone.component_ids { + let component = &ical.components[*component_id as usize]; + assert!(matches!( + component.component_type, + ICalendarComponentType::Standard | ICalendarComponentType::Daylight + )); + + let date_time = observance_date_time(component); + let from_offset = observance_offset(component, &ICalendarProperty::Tzoffsetfrom); + let to_offset = observance_offset(component, &ICalendarProperty::Tzoffsetto); + onsets.push(( + date_time.and_utc().timestamp() - i64::from(from_offset), + to_offset, + )); + + let Some(rule) = component + .entries + .iter() + .find(|entry| entry.name == ICalendarProperty::Rrule) + .and_then(|entry| match entry.values.first() { + Some(ICalendarValue::RecurrenceRule(rule)) => Some(rule), + _ => None, + }) + else { + continue; + }; + + assert_eq!(rule.freq, ICalendarFrequency::Yearly); + assert_eq!(rule.byday.len(), 1); + assert_eq!(rule.bymonth.len(), 1); + assert!(rule.count.is_none()); + + let day = rule.byday[0]; + let month = rule.bymonth[0].month(); + let until = rule + .until + .as_ref() + .and_then(|until| until.to_date_time()) + .map(|until| until.date_time.and_utc().timestamp()); + + for year in date_time.year() + 1..=date_time.year() + 200 { + let Some(date) = + nth_weekday(year, month, day.ordwk.unwrap_or_default(), day.weekday) + else { + continue; + }; + let onset = + date.and_time(date_time.time()).and_utc().timestamp() - i64::from(from_offset); + if until.is_some_and(|until| onset > until) { + break; + } + onsets.push((onset, to_offset)); + } + } + + onsets.sort_unstable(); + onsets + } + + /// RFC 5545, Section 3.6.5: the offset in effect is the one of the observance with the + /// last onset before the time in question. + fn offset_at(onsets: &[(i64, i32)], at: i64) -> Option { + onsets + .partition_point(|(onset, _)| *onset <= at) + .checked_sub(1) + .map(|position| onsets[position].1) + } + + fn observance_value<'x>( + component: &'x ICalendarComponent, + name: &ICalendarProperty, + ) -> &'x ICalendarValue { + component + .entries + .iter() + .find(|entry| &entry.name == name) + .and_then(|entry| entry.values.first()) + .unwrap_or_else(|| panic!("missing {name:?}")) + } + + fn observance_offset(component: &ICalendarComponent, name: &ICalendarProperty) -> i32 { + let ICalendarValue::PartialDateTime(offset) = observance_value(component, name) else { + panic!("{name:?} is not a UTC offset"); + }; + let seconds = i32::from(offset.tz_hour.unwrap_or_default()) * 3600 + + i32::from(offset.tz_minute.unwrap_or_default()) * 60; + + if offset.tz_minus { -seconds } else { seconds } + } + + fn observance_date_time(component: &ICalendarComponent) -> NaiveDateTime { + let ICalendarValue::PartialDateTime(dtstart) = + observance_value(component, &ICalendarProperty::Dtstart) + else { + panic!("DTSTART is not a date-time"); + }; + assert!( + dtstart.tz_hour.is_none() && dtstart.tz_minute.is_none(), + "DTSTART must be a local time value" + ); + + dtstart.to_date_time().expect("invalid DTSTART").date_time + } + + fn nth_weekday( + year: i32, + month: u8, + ordwk: i16, + weekday: ICalendarWeekday, + ) -> Option { + let matches = (1..=days_in_month(year, u32::from(month))) + .filter_map(|day| NaiveDate::from_ymd_opt(year, u32::from(month), day)) + .filter(|date| weekday_of(date.weekday()) == weekday) + .collect::>(); + + if ordwk < 0 { + matches + .len() + .checked_sub(usize::from(ordwk.unsigned_abs())) + .and_then(|index| matches.get(index)) + .copied() + } else { + matches.get((ordwk as usize).saturating_sub(1)).copied() + } + } +} diff --git a/vendor/calcard/src/icalendar/types.rs b/vendor/calcard/src/icalendar/types.rs new file mode 100644 index 00000000..e719a38b --- /dev/null +++ b/vendor/calcard/src/icalendar/types.rs @@ -0,0 +1,1458 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; +use crate::{Entry, Parser}; +use std::borrow::Cow; + +impl IanaParse for ICalendarProperty { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "CALSCALE" => ICalendarProperty::Calscale, + "METHOD" => ICalendarProperty::Method, + "PRODID" => ICalendarProperty::Prodid, + "VERSION" => ICalendarProperty::Version, + "ATTACH" => ICalendarProperty::Attach, + "CATEGORIES" => ICalendarProperty::Categories, + "CLASS" => ICalendarProperty::Class, + "COMMENT" => ICalendarProperty::Comment, + "DESCRIPTION" => ICalendarProperty::Description, + "GEO" => ICalendarProperty::Geo, + "LOCATION" => ICalendarProperty::Location, + "PERCENT-COMPLETE" => ICalendarProperty::PercentComplete, + "PRIORITY" => ICalendarProperty::Priority, + "RESOURCES" => ICalendarProperty::Resources, + "STATUS" => ICalendarProperty::Status, + "SUMMARY" => ICalendarProperty::Summary, + "COMPLETED" => ICalendarProperty::Completed, + "DTEND" => ICalendarProperty::Dtend, + "DUE" => ICalendarProperty::Due, + "DTSTART" => ICalendarProperty::Dtstart, + "DURATION" => ICalendarProperty::Duration, + "FREEBUSY" => ICalendarProperty::Freebusy, + "TRANSP" => ICalendarProperty::Transp, + "TZID" => ICalendarProperty::Tzid, + "TZNAME" => ICalendarProperty::Tzname, + "TZOFFSETFROM" => ICalendarProperty::Tzoffsetfrom, + "TZOFFSETTO" => ICalendarProperty::Tzoffsetto, + "TZURL" => ICalendarProperty::Tzurl, + "ATTENDEE" => ICalendarProperty::Attendee, + "CONTACT" => ICalendarProperty::Contact, + "ORGANIZER" => ICalendarProperty::Organizer, + "RECURRENCE-ID" => ICalendarProperty::RecurrenceId, + "RELATED-TO" => ICalendarProperty::RelatedTo, + "URL" => ICalendarProperty::Url, + "UID" => ICalendarProperty::Uid, + "EXDATE" => ICalendarProperty::Exdate, + "EXRULE" => ICalendarProperty::Exrule, + "RDATE" => ICalendarProperty::Rdate, + "RRULE" => ICalendarProperty::Rrule, + "ACTION" => ICalendarProperty::Action, + "REPEAT" => ICalendarProperty::Repeat, + "TRIGGER" => ICalendarProperty::Trigger, + "CREATED" => ICalendarProperty::Created, + "DTSTAMP" => ICalendarProperty::Dtstamp, + "LAST-MODIFIED" => ICalendarProperty::LastModified, + "SEQUENCE" => ICalendarProperty::Sequence, + "REQUEST-STATUS" => ICalendarProperty::RequestStatus, + "XML" => ICalendarProperty::Xml, + "TZUNTIL" => ICalendarProperty::Tzuntil, + "TZID-ALIAS-OF" => ICalendarProperty::TzidAliasOf, + "BUSYTYPE" => ICalendarProperty::Busytype, + "NAME" => ICalendarProperty::Name, + "REFRESH-INTERVAL" => ICalendarProperty::RefreshInterval, + "SOURCE" => ICalendarProperty::Source, + "COLOR" => ICalendarProperty::Color, + "IMAGE" => ICalendarProperty::Image, + "CONFERENCE" => ICalendarProperty::Conference, + "CALENDAR-ADDRESS" => ICalendarProperty::CalendarAddress, + "LOCATION-TYPE" => ICalendarProperty::LocationType, + "PARTICIPANT-TYPE" => ICalendarProperty::ParticipantType, + "RESOURCE-TYPE" => ICalendarProperty::ResourceType, + "STRUCTURED-DATA" => ICalendarProperty::StructuredData, + "STYLED-DESCRIPTION" => ICalendarProperty::StyledDescription, + "ACKNOWLEDGED" => ICalendarProperty::Acknowledged, + "PROXIMITY" => ICalendarProperty::Proximity, + "CONCEPT" => ICalendarProperty::Concept, + "LINK" => ICalendarProperty::Link, + "REFID" => ICalendarProperty::Refid, + "COORDINATES" => ICalendarProperty::Coordinates, + "SHOW-WITHOUT-TIME" => ICalendarProperty::ShowWithoutTime, + "JSID" => ICalendarProperty::Jsid, + "JSPROP" => ICalendarProperty::Jsprop, + "BEGIN" => ICalendarProperty::Begin, + "END" => ICalendarProperty::End, + "ESTIMATED-DURATION" => ICalendarProperty::EstimatedDuration, + "REASON" => ICalendarProperty::Reason, + "SUBSTATE" => ICalendarProperty::Substate, + "TASK-MODE" => ICalendarProperty::TaskMode, + ) + } +} + +impl ICalendarProperty { + pub fn as_str(&self) -> &str { + match self { + ICalendarProperty::Calscale => "CALSCALE", + ICalendarProperty::Method => "METHOD", + ICalendarProperty::Prodid => "PRODID", + ICalendarProperty::Version => "VERSION", + ICalendarProperty::Attach => "ATTACH", + ICalendarProperty::Categories => "CATEGORIES", + ICalendarProperty::Class => "CLASS", + ICalendarProperty::Comment => "COMMENT", + ICalendarProperty::Description => "DESCRIPTION", + ICalendarProperty::Geo => "GEO", + ICalendarProperty::Location => "LOCATION", + ICalendarProperty::PercentComplete => "PERCENT-COMPLETE", + ICalendarProperty::Priority => "PRIORITY", + ICalendarProperty::Resources => "RESOURCES", + ICalendarProperty::Status => "STATUS", + ICalendarProperty::Summary => "SUMMARY", + ICalendarProperty::Completed => "COMPLETED", + ICalendarProperty::Dtend => "DTEND", + ICalendarProperty::Due => "DUE", + ICalendarProperty::Dtstart => "DTSTART", + ICalendarProperty::Duration => "DURATION", + ICalendarProperty::Freebusy => "FREEBUSY", + ICalendarProperty::Transp => "TRANSP", + ICalendarProperty::Tzid => "TZID", + ICalendarProperty::Tzname => "TZNAME", + ICalendarProperty::Tzoffsetfrom => "TZOFFSETFROM", + ICalendarProperty::Tzoffsetto => "TZOFFSETTO", + ICalendarProperty::Tzurl => "TZURL", + ICalendarProperty::Attendee => "ATTENDEE", + ICalendarProperty::Contact => "CONTACT", + ICalendarProperty::Organizer => "ORGANIZER", + ICalendarProperty::RecurrenceId => "RECURRENCE-ID", + ICalendarProperty::RelatedTo => "RELATED-TO", + ICalendarProperty::Url => "URL", + ICalendarProperty::Uid => "UID", + ICalendarProperty::Exdate => "EXDATE", + ICalendarProperty::Exrule => "EXRULE", + ICalendarProperty::Rdate => "RDATE", + ICalendarProperty::Rrule => "RRULE", + ICalendarProperty::Action => "ACTION", + ICalendarProperty::Repeat => "REPEAT", + ICalendarProperty::Trigger => "TRIGGER", + ICalendarProperty::Created => "CREATED", + ICalendarProperty::Dtstamp => "DTSTAMP", + ICalendarProperty::LastModified => "LAST-MODIFIED", + ICalendarProperty::Sequence => "SEQUENCE", + ICalendarProperty::RequestStatus => "REQUEST-STATUS", + ICalendarProperty::Xml => "XML", + ICalendarProperty::Tzuntil => "TZUNTIL", + ICalendarProperty::TzidAliasOf => "TZID-ALIAS-OF", + ICalendarProperty::Busytype => "BUSYTYPE", + ICalendarProperty::Name => "NAME", + ICalendarProperty::RefreshInterval => "REFRESH-INTERVAL", + ICalendarProperty::Source => "SOURCE", + ICalendarProperty::Color => "COLOR", + ICalendarProperty::Image => "IMAGE", + ICalendarProperty::Conference => "CONFERENCE", + ICalendarProperty::CalendarAddress => "CALENDAR-ADDRESS", + ICalendarProperty::LocationType => "LOCATION-TYPE", + ICalendarProperty::ParticipantType => "PARTICIPANT-TYPE", + ICalendarProperty::ResourceType => "RESOURCE-TYPE", + ICalendarProperty::StructuredData => "STRUCTURED-DATA", + ICalendarProperty::StyledDescription => "STYLED-DESCRIPTION", + ICalendarProperty::Acknowledged => "ACKNOWLEDGED", + ICalendarProperty::Proximity => "PROXIMITY", + ICalendarProperty::Concept => "CONCEPT", + ICalendarProperty::Link => "LINK", + ICalendarProperty::Refid => "REFID", + ICalendarProperty::Begin => "BEGIN", + ICalendarProperty::End => "END", + ICalendarProperty::Coordinates => "COORDINATES", + ICalendarProperty::ShowWithoutTime => "SHOW-WITHOUT-TIME", + ICalendarProperty::Jsid => "JSID", + ICalendarProperty::Jsprop => "JSPROP", + ICalendarProperty::EstimatedDuration => "ESTIMATED-DURATION", + ICalendarProperty::Reason => "REASON", + ICalendarProperty::Substate => "SUBSTATE", + ICalendarProperty::TaskMode => "TASK-MODE", + ICalendarProperty::Other(s) => s.as_str(), + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + ICalendarProperty::Calscale => "CALSCALE", + ICalendarProperty::Method => "METHOD", + ICalendarProperty::Prodid => "PRODID", + ICalendarProperty::Version => "VERSION", + ICalendarProperty::Attach => "ATTACH", + ICalendarProperty::Categories => "CATEGORIES", + ICalendarProperty::Class => "CLASS", + ICalendarProperty::Comment => "COMMENT", + ICalendarProperty::Description => "DESCRIPTION", + ICalendarProperty::Geo => "GEO", + ICalendarProperty::Location => "LOCATION", + ICalendarProperty::PercentComplete => "PERCENT-COMPLETE", + ICalendarProperty::Priority => "PRIORITY", + ICalendarProperty::Resources => "RESOURCES", + ICalendarProperty::Status => "STATUS", + ICalendarProperty::Summary => "SUMMARY", + ICalendarProperty::Completed => "COMPLETED", + ICalendarProperty::Dtend => "DTEND", + ICalendarProperty::Due => "DUE", + ICalendarProperty::Dtstart => "DTSTART", + ICalendarProperty::Duration => "DURATION", + ICalendarProperty::Freebusy => "FREEBUSY", + ICalendarProperty::Transp => "TRANSP", + ICalendarProperty::Tzid => "TZID", + ICalendarProperty::Tzname => "TZNAME", + ICalendarProperty::Tzoffsetfrom => "TZOFFSETFROM", + ICalendarProperty::Tzoffsetto => "TZOFFSETTO", + ICalendarProperty::Tzurl => "TZURL", + ICalendarProperty::Attendee => "ATTENDEE", + ICalendarProperty::Contact => "CONTACT", + ICalendarProperty::Organizer => "ORGANIZER", + ICalendarProperty::RecurrenceId => "RECURRENCE-ID", + ICalendarProperty::RelatedTo => "RELATED-TO", + ICalendarProperty::Url => "URL", + ICalendarProperty::Uid => "UID", + ICalendarProperty::Exdate => "EXDATE", + ICalendarProperty::Exrule => "EXRULE", + ICalendarProperty::Rdate => "RDATE", + ICalendarProperty::Rrule => "RRULE", + ICalendarProperty::Action => "ACTION", + ICalendarProperty::Repeat => "REPEAT", + ICalendarProperty::Trigger => "TRIGGER", + ICalendarProperty::Created => "CREATED", + ICalendarProperty::Dtstamp => "DTSTAMP", + ICalendarProperty::LastModified => "LAST-MODIFIED", + ICalendarProperty::Sequence => "SEQUENCE", + ICalendarProperty::RequestStatus => "REQUEST-STATUS", + ICalendarProperty::Xml => "XML", + ICalendarProperty::Tzuntil => "TZUNTIL", + ICalendarProperty::TzidAliasOf => "TZID-ALIAS-OF", + ICalendarProperty::Busytype => "BUSYTYPE", + ICalendarProperty::Name => "NAME", + ICalendarProperty::RefreshInterval => "REFRESH-INTERVAL", + ICalendarProperty::Source => "SOURCE", + ICalendarProperty::Color => "COLOR", + ICalendarProperty::Image => "IMAGE", + ICalendarProperty::Conference => "CONFERENCE", + ICalendarProperty::CalendarAddress => "CALENDAR-ADDRESS", + ICalendarProperty::LocationType => "LOCATION-TYPE", + ICalendarProperty::ParticipantType => "PARTICIPANT-TYPE", + ICalendarProperty::ResourceType => "RESOURCE-TYPE", + ICalendarProperty::StructuredData => "STRUCTURED-DATA", + ICalendarProperty::StyledDescription => "STYLED-DESCRIPTION", + ICalendarProperty::Acknowledged => "ACKNOWLEDGED", + ICalendarProperty::Proximity => "PROXIMITY", + ICalendarProperty::Concept => "CONCEPT", + ICalendarProperty::Link => "LINK", + ICalendarProperty::Refid => "REFID", + ICalendarProperty::Begin => "BEGIN", + ICalendarProperty::End => "END", + ICalendarProperty::Coordinates => "COORDINATES", + ICalendarProperty::ShowWithoutTime => "SHOW-WITHOUT-TIME", + ICalendarProperty::Jsid => "JSID", + ICalendarProperty::Jsprop => "JSPROP", + ICalendarProperty::EstimatedDuration => "ESTIMATED-DURATION", + ICalendarProperty::Reason => "REASON", + ICalendarProperty::Substate => "SUBSTATE", + ICalendarProperty::TaskMode => "TASK-MODE", + ICalendarProperty::Other(s) => return Cow::Owned(s), + } + .into() + } +} + +impl ICalendarParameterName { + pub fn try_parse(input: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(input, + b"ALTREP" => ICalendarParameterName::Altrep, + b"CN" => ICalendarParameterName::Cn, + b"CUTYPE" => ICalendarParameterName::Cutype, + b"DELEGATED-FROM" => ICalendarParameterName::DelegatedFrom, + b"DELEGATED-TO" => ICalendarParameterName::DelegatedTo, + b"DIR" => ICalendarParameterName::Dir, + b"FMTTYPE" => ICalendarParameterName::Fmttype, + b"FBTYPE" => ICalendarParameterName::Fbtype, + b"LANGUAGE" => ICalendarParameterName::Language, + b"MEMBER" => ICalendarParameterName::Member, + b"PARTSTAT" => ICalendarParameterName::Partstat, + b"RANGE" => ICalendarParameterName::Range, + b"RELATED" => ICalendarParameterName::Related, + b"RELTYPE" => ICalendarParameterName::Reltype, + b"ROLE" => ICalendarParameterName::Role, + b"RSVP" => ICalendarParameterName::Rsvp, + b"SCHEDULE-AGENT" => ICalendarParameterName::ScheduleAgent, + b"SCHEDULE-FORCE-SEND" => ICalendarParameterName::ScheduleForceSend, + b"SCHEDULE-STATUS" => ICalendarParameterName::ScheduleStatus, + b"SENT-BY" => ICalendarParameterName::SentBy, + b"TZID" => ICalendarParameterName::Tzid, + b"VALUE" => ICalendarParameterName::Value, + b"DISPLAY" => ICalendarParameterName::Display, + b"EMAIL" => ICalendarParameterName::Email, + b"FEATURE" => ICalendarParameterName::Feature, + b"LABEL" => ICalendarParameterName::Label, + b"SIZE" => ICalendarParameterName::Size, + b"FILENAME" => ICalendarParameterName::Filename, + b"MANAGED-ID" => ICalendarParameterName::ManagedId, + b"ORDER" => ICalendarParameterName::Order, + b"SCHEMA" => ICalendarParameterName::Schema, + b"DERIVED" => ICalendarParameterName::Derived, + b"GAP" => ICalendarParameterName::Gap, + b"LINKREL" => ICalendarParameterName::Linkrel, + b"JSPTR" => ICalendarParameterName::Jsptr, + b"JSID" => ICalendarParameterName::Jsid, + ) + } + + pub fn as_str(&self) -> &str { + match self { + ICalendarParameterName::Altrep => "ALTREP", + ICalendarParameterName::Cn => "CN", + ICalendarParameterName::Cutype => "CUTYPE", + ICalendarParameterName::DelegatedFrom => "DELEGATED-FROM", + ICalendarParameterName::DelegatedTo => "DELEGATED-TO", + ICalendarParameterName::Dir => "DIR", + ICalendarParameterName::Fmttype => "FMTTYPE", + ICalendarParameterName::Fbtype => "FBTYPE", + ICalendarParameterName::Language => "LANGUAGE", + ICalendarParameterName::Member => "MEMBER", + ICalendarParameterName::Partstat => "PARTSTAT", + ICalendarParameterName::Range => "RANGE", + ICalendarParameterName::Related => "RELATED", + ICalendarParameterName::Reltype => "RELTYPE", + ICalendarParameterName::Role => "ROLE", + ICalendarParameterName::Rsvp => "RSVP", + ICalendarParameterName::ScheduleAgent => "SCHEDULE-AGENT", + ICalendarParameterName::ScheduleForceSend => "SCHEDULE-FORCE-SEND", + ICalendarParameterName::ScheduleStatus => "SCHEDULE-STATUS", + ICalendarParameterName::SentBy => "SENT-BY", + ICalendarParameterName::Tzid => "TZID", + ICalendarParameterName::Value => "VALUE", + ICalendarParameterName::Display => "DISPLAY", + ICalendarParameterName::Email => "EMAIL", + ICalendarParameterName::Feature => "FEATURE", + ICalendarParameterName::Label => "LABEL", + ICalendarParameterName::Size => "SIZE", + ICalendarParameterName::Filename => "FILENAME", + ICalendarParameterName::ManagedId => "MANAGED-ID", + ICalendarParameterName::Order => "ORDER", + ICalendarParameterName::Schema => "SCHEMA", + ICalendarParameterName::Derived => "DERIVED", + ICalendarParameterName::Gap => "GAP", + ICalendarParameterName::Linkrel => "LINKREL", + ICalendarParameterName::Jsptr => "JSPTR", + ICalendarParameterName::Jsid => "JSID", + ICalendarParameterName::Other(name) => name.as_str(), + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + ICalendarParameterName::Altrep => "ALTREP", + ICalendarParameterName::Cn => "CN", + ICalendarParameterName::Cutype => "CUTYPE", + ICalendarParameterName::DelegatedFrom => "DELEGATED-FROM", + ICalendarParameterName::DelegatedTo => "DELEGATED-TO", + ICalendarParameterName::Dir => "DIR", + ICalendarParameterName::Fmttype => "FMTTYPE", + ICalendarParameterName::Fbtype => "FBTYPE", + ICalendarParameterName::Language => "LANGUAGE", + ICalendarParameterName::Member => "MEMBER", + ICalendarParameterName::Partstat => "PARTSTAT", + ICalendarParameterName::Range => "RANGE", + ICalendarParameterName::Related => "RELATED", + ICalendarParameterName::Reltype => "RELTYPE", + ICalendarParameterName::Role => "ROLE", + ICalendarParameterName::Rsvp => "RSVP", + ICalendarParameterName::ScheduleAgent => "SCHEDULE-AGENT", + ICalendarParameterName::ScheduleForceSend => "SCHEDULE-FORCE-SEND", + ICalendarParameterName::ScheduleStatus => "SCHEDULE-STATUS", + ICalendarParameterName::SentBy => "SENT-BY", + ICalendarParameterName::Tzid => "TZID", + ICalendarParameterName::Value => "VALUE", + ICalendarParameterName::Display => "DISPLAY", + ICalendarParameterName::Email => "EMAIL", + ICalendarParameterName::Feature => "FEATURE", + ICalendarParameterName::Label => "LABEL", + ICalendarParameterName::Size => "SIZE", + ICalendarParameterName::Filename => "FILENAME", + ICalendarParameterName::ManagedId => "MANAGED-ID", + ICalendarParameterName::Order => "ORDER", + ICalendarParameterName::Schema => "SCHEMA", + ICalendarParameterName::Derived => "DERIVED", + ICalendarParameterName::Gap => "GAP", + ICalendarParameterName::Linkrel => "LINKREL", + ICalendarParameterName::Jsptr => "JSPTR", + ICalendarParameterName::Jsid => "JSID", + ICalendarParameterName::Other(name) => return Cow::Owned(name), + } + .into() + } + + pub fn parse(input: &str) -> Self { + Self::try_parse(input.as_bytes()) + .unwrap_or_else(|| ICalendarParameterName::Other(input.to_string())) + } +} + +impl IanaParse for ICalendarFrequency { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + b"SECONDLY" => ICalendarFrequency::Secondly, + b"MINUTELY" => ICalendarFrequency::Minutely, + b"HOURLY" => ICalendarFrequency::Hourly, + b"DAILY" => ICalendarFrequency::Daily, + b"WEEKLY" => ICalendarFrequency::Weekly, + b"MONTHLY" => ICalendarFrequency::Monthly, + b"YEARLY" => ICalendarFrequency::Yearly, + ) + } +} + +impl IanaString for ICalendarFrequency { + fn as_str(&self) -> &'static str { + match self { + ICalendarFrequency::Secondly => "SECONDLY", + ICalendarFrequency::Minutely => "MINUTELY", + ICalendarFrequency::Hourly => "HOURLY", + ICalendarFrequency::Daily => "DAILY", + ICalendarFrequency::Weekly => "WEEKLY", + ICalendarFrequency::Monthly => "MONTHLY", + ICalendarFrequency::Yearly => "YEARLY", + } + } +} + +impl IanaParse for ICalendarSkip { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + b"OMIT" => ICalendarSkip::Omit, + b"BACKWARD" => ICalendarSkip::Backward, + b"FORWARD" => ICalendarSkip::Forward, + ) + } +} + +impl IanaString for ICalendarSkip { + fn as_str(&self) -> &'static str { + match self { + ICalendarSkip::Omit => "OMIT", + ICalendarSkip::Backward => "BACKWARD", + ICalendarSkip::Forward => "FORWARD", + } + } +} + +impl IanaParse for ICalendarWeekday { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + b"SU" => ICalendarWeekday::Sunday, + b"MO" => ICalendarWeekday::Monday, + b"TU" => ICalendarWeekday::Tuesday, + b"WE" => ICalendarWeekday::Wednesday, + b"TH" => ICalendarWeekday::Thursday, + b"FR" => ICalendarWeekday::Friday, + b"SA" => ICalendarWeekday::Saturday, + ) + } +} + +impl IanaString for ICalendarWeekday { + fn as_str(&self) -> &'static str { + match self { + ICalendarWeekday::Sunday => "SU", + ICalendarWeekday::Monday => "MO", + ICalendarWeekday::Tuesday => "TU", + ICalendarWeekday::Wednesday => "WE", + ICalendarWeekday::Thursday => "TH", + ICalendarWeekday::Friday => "FR", + ICalendarWeekday::Saturday => "SA", + } + } +} + +impl From for chrono::Weekday { + fn from(value: ICalendarWeekday) -> Self { + match value { + ICalendarWeekday::Sunday => chrono::Weekday::Sun, + ICalendarWeekday::Monday => chrono::Weekday::Mon, + ICalendarWeekday::Tuesday => chrono::Weekday::Tue, + ICalendarWeekday::Wednesday => chrono::Weekday::Wed, + ICalendarWeekday::Thursday => chrono::Weekday::Thu, + ICalendarWeekday::Friday => chrono::Weekday::Fri, + ICalendarWeekday::Saturday => chrono::Weekday::Sat, + } + } +} + +impl IanaParse for ICalendarAction { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "AUDIO" => ICalendarAction::Audio, + "DISPLAY" => ICalendarAction::Display, + "EMAIL" => ICalendarAction::Email, + "PROCEDURE" => ICalendarAction::Procedure, + ) + } +} + +impl IanaString for ICalendarAction { + fn as_str(&self) -> &'static str { + match self { + ICalendarAction::Audio => "AUDIO", + ICalendarAction::Display => "DISPLAY", + ICalendarAction::Email => "EMAIL", + ICalendarAction::Procedure => "PROCEDURE", + } + } +} + +impl IanaParse for ICalendarUserTypes { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "INDIVIDUAL" => ICalendarUserTypes::Individual, + "GROUP" => ICalendarUserTypes::Group, + "RESOURCE" => ICalendarUserTypes::Resource, + "ROOM" => ICalendarUserTypes::Room, + "UNKNOWN" => ICalendarUserTypes::Unknown, + ) + } +} + +impl IanaString for ICalendarUserTypes { + fn as_str(&self) -> &'static str { + match self { + ICalendarUserTypes::Individual => "INDIVIDUAL", + ICalendarUserTypes::Group => "GROUP", + ICalendarUserTypes::Resource => "RESOURCE", + ICalendarUserTypes::Room => "ROOM", + ICalendarUserTypes::Unknown => "UNKNOWN", + } + } +} + +impl IanaParse for ICalendarClassification { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "PUBLIC" => ICalendarClassification::Public, + "PRIVATE" => ICalendarClassification::Private, + "CONFIDENTIAL" => ICalendarClassification::Confidential, + ) + } +} + +impl IanaString for ICalendarClassification { + fn as_str(&self) -> &'static str { + match self { + ICalendarClassification::Public => "PUBLIC", + ICalendarClassification::Private => "PRIVATE", + ICalendarClassification::Confidential => "CONFIDENTIAL", + } + } +} + +impl IanaParse for ICalendarComponentType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "VCALENDAR" => ICalendarComponentType::VCalendar, + "VEVENT" => ICalendarComponentType::VEvent, + "VTODO" => ICalendarComponentType::VTodo, + "VJOURNAL" => ICalendarComponentType::VJournal, + "VFREEBUSY" => ICalendarComponentType::VFreebusy, + "VTIMEZONE" => ICalendarComponentType::VTimezone, + "VALARM" => ICalendarComponentType::VAlarm, + "STANDARD" => ICalendarComponentType::Standard, + "DAYLIGHT" => ICalendarComponentType::Daylight, + "VAVAILABILITY" => ICalendarComponentType::VAvailability, + "AVAILABLE" => ICalendarComponentType::Available, + "PARTICIPANT" => ICalendarComponentType::Participant, + "VLOCATION" => ICalendarComponentType::VLocation, + "VRESOURCE" => ICalendarComponentType::VResource, + "VSTATUS" => ICalendarComponentType::VStatus + ) + } +} + +impl ICalendarComponentType { + pub fn as_str(&self) -> &str { + match self { + ICalendarComponentType::VCalendar => "VCALENDAR", + ICalendarComponentType::VEvent => "VEVENT", + ICalendarComponentType::VTodo => "VTODO", + ICalendarComponentType::VJournal => "VJOURNAL", + ICalendarComponentType::VFreebusy => "VFREEBUSY", + ICalendarComponentType::VTimezone => "VTIMEZONE", + ICalendarComponentType::VAlarm => "VALARM", + ICalendarComponentType::Standard => "STANDARD", + ICalendarComponentType::Daylight => "DAYLIGHT", + ICalendarComponentType::VAvailability => "VAVAILABILITY", + ICalendarComponentType::Available => "AVAILABLE", + ICalendarComponentType::Participant => "PARTICIPANT", + ICalendarComponentType::VLocation => "VLOCATION", + ICalendarComponentType::VResource => "VRESOURCE", + ICalendarComponentType::VStatus => "VSTATUS", + ICalendarComponentType::Other(s) => s.as_str(), + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + ICalendarComponentType::VCalendar => "VCALENDAR", + ICalendarComponentType::VEvent => "VEVENT", + ICalendarComponentType::VTodo => "VTODO", + ICalendarComponentType::VJournal => "VJOURNAL", + ICalendarComponentType::VFreebusy => "VFREEBUSY", + ICalendarComponentType::VTimezone => "VTIMEZONE", + ICalendarComponentType::VAlarm => "VALARM", + ICalendarComponentType::Standard => "STANDARD", + ICalendarComponentType::Daylight => "DAYLIGHT", + ICalendarComponentType::VAvailability => "VAVAILABILITY", + ICalendarComponentType::Available => "AVAILABLE", + ICalendarComponentType::Participant => "PARTICIPANT", + ICalendarComponentType::VLocation => "VLOCATION", + ICalendarComponentType::VResource => "VRESOURCE", + ICalendarComponentType::VStatus => "VSTATUS", + ICalendarComponentType::Other(s) => return Cow::Owned(s), + } + .into() + } +} + +impl IanaParse for ICalendarDisplayType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "BADGE" => ICalendarDisplayType::Badge, + "GRAPHIC" => ICalendarDisplayType::Graphic, + "FULLSIZE" => ICalendarDisplayType::Fullsize, + "THUMBNAIL" => ICalendarDisplayType::Thumbnail, + ) + } +} + +impl IanaString for ICalendarDisplayType { + fn as_str(&self) -> &'static str { + match self { + ICalendarDisplayType::Badge => "BADGE", + ICalendarDisplayType::Graphic => "GRAPHIC", + ICalendarDisplayType::Fullsize => "FULLSIZE", + ICalendarDisplayType::Thumbnail => "THUMBNAIL", + } + } +} + +impl IanaParse for ICalendarFeatureType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "AUDIO" => ICalendarFeatureType::Audio, + "CHAT" => ICalendarFeatureType::Chat, + "FEED" => ICalendarFeatureType::Feed, + "MODERATOR" => ICalendarFeatureType::Moderator, + "PHONE" => ICalendarFeatureType::Phone, + "SCREEN" => ICalendarFeatureType::Screen, + "VIDEO" => ICalendarFeatureType::Video, + ) + } +} + +impl IanaString for ICalendarFeatureType { + fn as_str(&self) -> &'static str { + match self { + ICalendarFeatureType::Audio => "AUDIO", + ICalendarFeatureType::Chat => "CHAT", + ICalendarFeatureType::Feed => "FEED", + ICalendarFeatureType::Moderator => "MODERATOR", + ICalendarFeatureType::Phone => "PHONE", + ICalendarFeatureType::Screen => "SCREEN", + ICalendarFeatureType::Video => "VIDEO", + } + } +} + +impl IanaParse for ICalendarFreeBusyType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "FREE" => ICalendarFreeBusyType::Free, + "BUSY" => ICalendarFreeBusyType::Busy, + "BUSY-UNAVAILABLE" => ICalendarFreeBusyType::BusyUnavailable, + "BUSY-TENTATIVE" => ICalendarFreeBusyType::BusyTentative, + ) + } +} + +impl IanaString for ICalendarFreeBusyType { + fn as_str(&self) -> &'static str { + match self { + ICalendarFreeBusyType::Free => "FREE", + ICalendarFreeBusyType::Busy => "BUSY", + ICalendarFreeBusyType::BusyUnavailable => "BUSY-UNAVAILABLE", + ICalendarFreeBusyType::BusyTentative => "BUSY-TENTATIVE", + } + } +} + +impl IanaParse for ICalendarMethod { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "PUBLISH" => ICalendarMethod::Publish, + "REQUEST" => ICalendarMethod::Request, + "REPLY" => ICalendarMethod::Reply, + "ADD" => ICalendarMethod::Add, + "CANCEL" => ICalendarMethod::Cancel, + "REFRESH" => ICalendarMethod::Refresh, + "COUNTER" => ICalendarMethod::Counter, + "DECLINECOUNTER" => ICalendarMethod::Declinecounter, + ) + } +} + +impl IanaString for ICalendarMethod { + fn as_str(&self) -> &'static str { + match self { + ICalendarMethod::Publish => "PUBLISH", + ICalendarMethod::Request => "REQUEST", + ICalendarMethod::Reply => "REPLY", + ICalendarMethod::Add => "ADD", + ICalendarMethod::Cancel => "CANCEL", + ICalendarMethod::Refresh => "REFRESH", + ICalendarMethod::Counter => "COUNTER", + ICalendarMethod::Declinecounter => "DECLINECOUNTER", + } + } +} + +impl IanaParse for ICalendarRelated { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "START" => ICalendarRelated::Start, + "END" => ICalendarRelated::End, + ) + } +} + +impl IanaString for ICalendarRelated { + fn as_str(&self) -> &'static str { + match self { + ICalendarRelated::Start => "START", + ICalendarRelated::End => "END", + } + } +} + +impl IanaParse for ICalendarParticipantType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "ACTIVE" => ICalendarParticipantType::Active, + "INACTIVE" => ICalendarParticipantType::Inactive, + "SPONSOR" => ICalendarParticipantType::Sponsor, + "CONTACT" => ICalendarParticipantType::Contact, + "BOOKING-CONTACT" => ICalendarParticipantType::BookingContact, + "EMERGENCY-CONTACT" => ICalendarParticipantType::EmergencyContact, + "PUBLICITY-CONTACT" => ICalendarParticipantType::PublicityContact, + "PLANNER-CONTACT" => ICalendarParticipantType::PlannerContact, + "PERFORMER" => ICalendarParticipantType::Performer, + "SPEAKER" => ICalendarParticipantType::Speaker, + ) + } +} + +impl IanaString for ICalendarParticipantType { + fn as_str(&self) -> &'static str { + match self { + ICalendarParticipantType::Active => "ACTIVE", + ICalendarParticipantType::Inactive => "INACTIVE", + ICalendarParticipantType::Sponsor => "SPONSOR", + ICalendarParticipantType::Contact => "CONTACT", + ICalendarParticipantType::BookingContact => "BOOKING-CONTACT", + ICalendarParticipantType::EmergencyContact => "EMERGENCY-CONTACT", + ICalendarParticipantType::PublicityContact => "PUBLICITY-CONTACT", + ICalendarParticipantType::PlannerContact => "PLANNER-CONTACT", + ICalendarParticipantType::Performer => "PERFORMER", + ICalendarParticipantType::Speaker => "SPEAKER", + } + } +} + +impl IanaParse for ICalendarParticipationRole { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "CHAIR" => ICalendarParticipationRole::Chair, + "REQ-PARTICIPANT" => ICalendarParticipationRole::ReqParticipant, + "OPT-PARTICIPANT" => ICalendarParticipationRole::OptParticipant, + "NON-PARTICIPANT" => ICalendarParticipationRole::NonParticipant, + "OWNER" => ICalendarParticipationRole::Owner, + ) + } +} + +impl IanaString for ICalendarParticipationRole { + fn as_str(&self) -> &'static str { + match self { + ICalendarParticipationRole::Chair => "CHAIR", + ICalendarParticipationRole::ReqParticipant => "REQ-PARTICIPANT", + ICalendarParticipationRole::OptParticipant => "OPT-PARTICIPANT", + ICalendarParticipationRole::NonParticipant => "NON-PARTICIPANT", + ICalendarParticipationRole::Owner => "OWNER", + } + } +} + +impl IanaParse for ICalendarStatus { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "TENTATIVE" => ICalendarStatus::Tentative, + "CONFIRMED" => ICalendarStatus::Confirmed, + "CANCELLED" => ICalendarStatus::Cancelled, + "NEEDS-ACTION" => ICalendarStatus::NeedsAction, + "COMPLETED" => ICalendarStatus::Completed, + "IN-PROCESS" => ICalendarStatus::InProcess, + "DRAFT" => ICalendarStatus::Draft, + "FINAL" => ICalendarStatus::Final, + "FAILED" => ICalendarStatus::Failed, + "PENDING" => ICalendarStatus::Pending + ) + } +} + +impl IanaString for ICalendarStatus { + fn as_str(&self) -> &'static str { + match self { + ICalendarStatus::Tentative => "TENTATIVE", + ICalendarStatus::Confirmed => "CONFIRMED", + ICalendarStatus::Cancelled => "CANCELLED", + ICalendarStatus::NeedsAction => "NEEDS-ACTION", + ICalendarStatus::Completed => "COMPLETED", + ICalendarStatus::InProcess => "IN-PROCESS", + ICalendarStatus::Draft => "DRAFT", + ICalendarStatus::Final => "FINAL", + ICalendarStatus::Failed => "FAILED", + ICalendarStatus::Pending => "PENDING", + } + } +} + +impl IanaParse for ICalendarParticipationStatus { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "NEEDS-ACTION" => ICalendarParticipationStatus::NeedsAction, + "ACCEPTED" => ICalendarParticipationStatus::Accepted, + "DECLINED" => ICalendarParticipationStatus::Declined, + "TENTATIVE" => ICalendarParticipationStatus::Tentative, + "DELEGATED" => ICalendarParticipationStatus::Delegated, + "COMPLETED" => ICalendarParticipationStatus::Completed, + "IN-PROCESS" => ICalendarParticipationStatus::InProcess, + "FAILED" => ICalendarParticipationStatus::Failed + ) + } +} + +impl IanaString for ICalendarParticipationStatus { + fn as_str(&self) -> &'static str { + match self { + ICalendarParticipationStatus::NeedsAction => "NEEDS-ACTION", + ICalendarParticipationStatus::Accepted => "ACCEPTED", + ICalendarParticipationStatus::Declined => "DECLINED", + ICalendarParticipationStatus::Tentative => "TENTATIVE", + ICalendarParticipationStatus::Delegated => "DELEGATED", + ICalendarParticipationStatus::Completed => "COMPLETED", + ICalendarParticipationStatus::InProcess => "IN-PROCESS", + ICalendarParticipationStatus::Failed => "FAILED", + } + } +} + +impl IanaParse for ICalendarProximityValue { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "ARRIVE" => ICalendarProximityValue::Arrive, + "DEPART" => ICalendarProximityValue::Depart, + "CONNECT" => ICalendarProximityValue::Connect, + "DISCONNECT" => ICalendarProximityValue::Disconnect, + ) + } +} + +impl IanaString for ICalendarProximityValue { + fn as_str(&self) -> &'static str { + match self { + ICalendarProximityValue::Arrive => "ARRIVE", + ICalendarProximityValue::Depart => "DEPART", + ICalendarProximityValue::Connect => "CONNECT", + ICalendarProximityValue::Disconnect => "DISCONNECT", + } + } +} + +impl IanaParse for ICalendarRelationshipType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "CHILD" => ICalendarRelationshipType::Child, + "PARENT" => ICalendarRelationshipType::Parent, + "SIBLING" => ICalendarRelationshipType::Sibling, + "SNOOZE" => ICalendarRelationshipType::Snooze, + "CONCEPT" => ICalendarRelationshipType::Concept, + "DEPENDS-ON" => ICalendarRelationshipType::DependsOn, + "FINISHTOFINISH" => ICalendarRelationshipType::Finishtofinish, + "FINISHTOSTART" => ICalendarRelationshipType::Finishtostart, + "FIRST" => ICalendarRelationshipType::First, + "NEXT" => ICalendarRelationshipType::Next, + "REFID" => ICalendarRelationshipType::Refid, + "STARTTOFINISH" => ICalendarRelationshipType::Starttofinish, + "STARTTOSTART" => ICalendarRelationshipType::Starttostart, + ) + } +} + +impl IanaString for ICalendarRelationshipType { + fn as_str(&self) -> &'static str { + match self { + ICalendarRelationshipType::Child => "CHILD", + ICalendarRelationshipType::Parent => "PARENT", + ICalendarRelationshipType::Sibling => "SIBLING", + ICalendarRelationshipType::Snooze => "SNOOZE", + ICalendarRelationshipType::Concept => "CONCEPT", + ICalendarRelationshipType::DependsOn => "DEPENDS-ON", + ICalendarRelationshipType::Finishtofinish => "FINISHTOFINISH", + ICalendarRelationshipType::Finishtostart => "FINISHTOSTART", + ICalendarRelationshipType::First => "FIRST", + ICalendarRelationshipType::Next => "NEXT", + ICalendarRelationshipType::Refid => "REFID", + ICalendarRelationshipType::Starttofinish => "STARTTOFINISH", + ICalendarRelationshipType::Starttostart => "STARTTOSTART", + } + } +} + +impl IanaParse for ICalendarResourceType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "PROJECTOR" => ICalendarResourceType::Projector, + "ROOM" => ICalendarResourceType::Room, + "REMOTE-CONFERENCE-AUDIO" => ICalendarResourceType::RemoteConferenceAudio, + "REMOTE-CONFERENCE-VIDEO" => ICalendarResourceType::RemoteConferenceVideo, + ) + } +} + +impl IanaString for ICalendarResourceType { + fn as_str(&self) -> &'static str { + match self { + ICalendarResourceType::Projector => "PROJECTOR", + ICalendarResourceType::Room => "ROOM", + ICalendarResourceType::RemoteConferenceAudio => "REMOTE-CONFERENCE-AUDIO", + ICalendarResourceType::RemoteConferenceVideo => "REMOTE-CONFERENCE-VIDEO", + } + } +} + +impl IanaParse for ICalendarScheduleAgentValue { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "SERVER" => ICalendarScheduleAgentValue::Server, + "CLIENT" => ICalendarScheduleAgentValue::Client, + "NONE" => ICalendarScheduleAgentValue::None, + ) + } +} + +impl IanaString for ICalendarScheduleAgentValue { + fn as_str(&self) -> &'static str { + match self { + ICalendarScheduleAgentValue::Server => "SERVER", + ICalendarScheduleAgentValue::Client => "CLIENT", + ICalendarScheduleAgentValue::None => "NONE", + } + } +} + +impl IanaParse for ICalendarScheduleForceSendValue { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "REQUEST" => ICalendarScheduleForceSendValue::Request, + "REPLY" => ICalendarScheduleForceSendValue::Reply, + ) + } +} + +impl IanaString for ICalendarScheduleForceSendValue { + fn as_str(&self) -> &'static str { + match self { + ICalendarScheduleForceSendValue::Request => "REQUEST", + ICalendarScheduleForceSendValue::Reply => "REPLY", + } + } +} + +impl IanaParse for ICalendarValueType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "BINARY" => ICalendarValueType::Binary, + "BOOLEAN" => ICalendarValueType::Boolean, + "CAL-ADDRESS" => ICalendarValueType::CalAddress, + "DATE" => ICalendarValueType::Date, + "DATE-TIME" => ICalendarValueType::DateTime, + "DURATION" => ICalendarValueType::Duration, + "FLOAT" => ICalendarValueType::Float, + "INTEGER" => ICalendarValueType::Integer, + "PERIOD" => ICalendarValueType::Period, + "RECUR" => ICalendarValueType::Recur, + "TEXT" => ICalendarValueType::Text, + "TIME" => ICalendarValueType::Time, + "UNKNOWN" => ICalendarValueType::Unknown, + "URI" => ICalendarValueType::Uri, + "UTC-OFFSET" => ICalendarValueType::UtcOffset, + "XML-REFERENCE" => ICalendarValueType::XmlReference, + "UID" => ICalendarValueType::Uid, + ) + } +} + +impl IanaString for ICalendarValueType { + fn as_str(&self) -> &'static str { + match self { + ICalendarValueType::Binary => "BINARY", + ICalendarValueType::Boolean => "BOOLEAN", + ICalendarValueType::CalAddress => "CAL-ADDRESS", + ICalendarValueType::Date => "DATE", + ICalendarValueType::DateTime => "DATE-TIME", + ICalendarValueType::Duration => "DURATION", + ICalendarValueType::Float => "FLOAT", + ICalendarValueType::Integer => "INTEGER", + ICalendarValueType::Period => "PERIOD", + ICalendarValueType::Recur => "RECUR", + ICalendarValueType::Text => "TEXT", + ICalendarValueType::Time => "TIME", + ICalendarValueType::Unknown => "UNKNOWN", + ICalendarValueType::Uri => "URI", + ICalendarValueType::UtcOffset => "UTC-OFFSET", + ICalendarValueType::XmlReference => "XML-REFERENCE", + ICalendarValueType::Uid => "UID", + } + } +} + +impl IanaParse for ICalendarTransparency { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "OPAQUE" => ICalendarTransparency::Opaque, + "TRANSPARENT" => ICalendarTransparency::Transparent, + ) + } +} + +impl IanaString for ICalendarTransparency { + fn as_str(&self) -> &'static str { + match self { + ICalendarTransparency::Opaque => "OPAQUE", + ICalendarTransparency::Transparent => "TRANSPARENT", + } + } +} + +impl ICalendarProperty { + // Returns the default value type and whether the property is multi-valued. + pub(crate) fn default_types(&self) -> (ValueType, ValueSeparator) { + match self { + ICalendarProperty::Calscale => (ValueType::CalendarScale, ValueSeparator::None), + ICalendarProperty::Method => (ValueType::Method, ValueSeparator::None), + ICalendarProperty::Prodid => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Version => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Attach => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Categories => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::Comma, + ), + ICalendarProperty::Class => (ValueType::Classification, ValueSeparator::None), + ICalendarProperty::Comment => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Description => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Geo => ( + ValueType::Ical(ICalendarValueType::Float), + ValueSeparator::Semicolon, + ), + ICalendarProperty::Location => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::PercentComplete => ( + ValueType::Ical(ICalendarValueType::Integer), + ValueSeparator::None, + ), + ICalendarProperty::Priority => ( + ValueType::Ical(ICalendarValueType::Integer), + ValueSeparator::None, + ), + ICalendarProperty::Resources => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::Comma, + ), + ICalendarProperty::Status => (ValueType::Status, ValueSeparator::None), + ICalendarProperty::Summary => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Completed => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Dtend => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Due => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Dtstart => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Duration => ( + ValueType::Ical(ICalendarValueType::Duration), + ValueSeparator::None, + ), + ICalendarProperty::Freebusy => ( + ValueType::Ical(ICalendarValueType::Period), + ValueSeparator::Comma, + ), + ICalendarProperty::Transp => (ValueType::Transparency, ValueSeparator::None), + ICalendarProperty::Tzid => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Tzname => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Tzoffsetfrom => ( + ValueType::Ical(ICalendarValueType::UtcOffset), + ValueSeparator::None, + ), + ICalendarProperty::Tzoffsetto => ( + ValueType::Ical(ICalendarValueType::UtcOffset), + ValueSeparator::None, + ), + ICalendarProperty::Tzurl => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Attendee => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Contact => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Organizer => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::RecurrenceId => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::RelatedTo => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Url => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Uid => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Exdate => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::Comma, + ), + ICalendarProperty::Exrule => ( + ValueType::Ical(ICalendarValueType::Recur), + ValueSeparator::None, + ), + ICalendarProperty::Rdate => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::Comma, + ), + ICalendarProperty::Rrule => ( + ValueType::Ical(ICalendarValueType::Recur), + ValueSeparator::None, + ), + ICalendarProperty::Action => (ValueType::Action, ValueSeparator::None), + ICalendarProperty::Repeat => ( + ValueType::Ical(ICalendarValueType::Integer), + ValueSeparator::None, + ), + ICalendarProperty::Trigger => ( + ValueType::Ical(ICalendarValueType::Duration), + ValueSeparator::None, + ), + ICalendarProperty::Created => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Dtstamp => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::LastModified => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Sequence => ( + ValueType::Ical(ICalendarValueType::Integer), + ValueSeparator::None, + ), + ICalendarProperty::RequestStatus => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::Semicolon, + ), + ICalendarProperty::Xml => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Tzuntil => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::TzidAliasOf => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Busytype => (ValueType::BusyType, ValueSeparator::None), + ICalendarProperty::Name => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::RefreshInterval => ( + ValueType::Ical(ICalendarValueType::Duration), + ValueSeparator::None, + ), + ICalendarProperty::Source => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Color => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Image => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Conference => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::CalendarAddress => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::LocationType => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::Comma, + ), + ICalendarProperty::ParticipantType => { + (ValueType::ParticipantType, ValueSeparator::None) + } + ICalendarProperty::ResourceType => (ValueType::ResourceType, ValueSeparator::None), + ICalendarProperty::StructuredData => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::StyledDescription => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Acknowledged => ( + ValueType::Ical(ICalendarValueType::DateTime), + ValueSeparator::None, + ), + ICalendarProperty::Proximity => (ValueType::Proximity, ValueSeparator::None), + ICalendarProperty::Concept => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Link => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Refid => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Begin => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::End => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::Other(_) => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::Semicolon, + ), + ICalendarProperty::Coordinates => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::ShowWithoutTime => ( + ValueType::Ical(ICalendarValueType::Boolean), + ValueSeparator::None, + ), + ICalendarProperty::Jsid | ICalendarProperty::Jsprop => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::EstimatedDuration => ( + ValueType::Ical(ICalendarValueType::Duration), + ValueSeparator::None, + ), + ICalendarProperty::Reason => ( + ValueType::Ical(ICalendarValueType::Uri), + ValueSeparator::None, + ), + ICalendarProperty::Substate => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + ICalendarProperty::TaskMode => ( + ValueType::Ical(ICalendarValueType::Text), + ValueSeparator::None, + ), + } + } +} + +impl ValueType { + pub fn unwrap_ical(self) -> ICalendarValueType { + match self { + ValueType::Ical(value) => value, + _ => ICalendarValueType::Text, + } + } +} + +impl ICalendar { + pub fn parse(value: impl AsRef) -> Result { + let mut parser = Parser::new(value.as_ref()); + match parser.entry() { + Entry::ICalendar(icalendar) => Ok(icalendar), + other => Err(other), + } + } +} + +impl Hash for ICalendarValue { + fn hash(&self, state: &mut H) { + match self { + ICalendarValue::Binary(value) => { + state.write_u8(0); + value.hash(state); + } + ICalendarValue::Boolean(value) => { + state.write_u8(1); + value.hash(state); + } + ICalendarValue::Uri(value) => { + state.write_u8(2); + value.hash(state); + } + ICalendarValue::PartialDateTime(value) => { + state.write_u8(3); + value.hash(state); + } + ICalendarValue::Duration(value) => { + state.write_u8(4); + value.hash(state); + } + ICalendarValue::RecurrenceRule(value) => { + state.write_u8(5); + value.hash(state); + } + ICalendarValue::Period(value) => { + state.write_u8(6); + value.hash(state); + } + ICalendarValue::Float(value) => { + state.write_u8(7); + value.to_bits().hash(state); + } + ICalendarValue::Integer(value) => { + state.write_u8(8); + value.hash(state); + } + ICalendarValue::Text(value) => { + state.write_u8(9); + value.hash(state); + } + ICalendarValue::CalendarScale(value) => { + state.write_u8(10); + value.hash(state); + } + ICalendarValue::Method(value) => { + state.write_u8(11); + value.hash(state); + } + ICalendarValue::Classification(value) => { + state.write_u8(12); + value.hash(state); + } + ICalendarValue::Status(value) => { + state.write_u8(13); + value.hash(state); + } + ICalendarValue::Transparency(value) => { + state.write_u8(14); + value.hash(state); + } + ICalendarValue::Action(value) => { + state.write_u8(15); + value.hash(state); + } + ICalendarValue::BusyType(value) => { + state.write_u8(16); + value.hash(state); + } + ICalendarValue::ParticipantType(value) => { + state.write_u8(17); + value.hash(state); + } + ICalendarValue::ResourceType(value) => { + state.write_u8(18); + value.hash(state); + } + ICalendarValue::Proximity(value) => { + state.write_u8(19); + value.hash(state); + } + } + } +} + +impl Eq for ICalendarValue {} + +#[allow(clippy::derive_ord_xor_partial_ord)] +impl Ord for ICalendarValue { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.partial_cmp(other).unwrap_or(std::cmp::Ordering::Equal) + } +} diff --git a/vendor/calcard/src/icalendar/utils.rs b/vendor/calcard/src/icalendar/utils.rs new file mode 100644 index 00000000..54dfd3a7 --- /dev/null +++ b/vendor/calcard/src/icalendar/utils.rs @@ -0,0 +1,526 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use std::borrow::Cow; + +use super::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry, + ICalendarParameterName, ICalendarProperty, ICalendarRecurrenceRule, ICalendarStatus, + ICalendarTransparency, ICalendarValue, Uri, +}; +use crate::{ + common::{IanaString, IanaType, PartialDateTime}, + icalendar::{ICalendarParameterValue, ICalendarValueType}, +}; + +pub fn strip_mailto_scheme(value: &str) -> &str { + value + .split_once(':') + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("mailto")) + .map_or(value, |(_, address)| address) +} + +impl ICalendar { + pub fn uids(&self) -> impl Iterator { + self.components + .iter() + .filter_map(|component| component.uid()) + } + + pub fn size(&self) -> usize { + self.components + .iter() + .map(|component| component.size()) + .sum() + } + + pub fn component_by_id(&self, id: u32) -> Option<&ICalendarComponent> { + self.components.get(id as usize) + } + + pub fn alarms_for_id(&self, id: u32) -> impl Iterator { + self.component_by_id(id) + .map_or(&[][..], |c| c.component_ids.as_slice()) + .iter() + .filter_map(|id| { + self.component_by_id(*id) + .filter(|c| c.component_type == ICalendarComponentType::VAlarm) + }) + } +} + +impl ICalendarComponent { + pub fn uid(&self) -> Option<&str> { + self.property(&ICalendarProperty::Uid) + .and_then(|e| e.values.first()) + .and_then(|v| v.as_text()) + } + + pub fn jsid(&self) -> Option<&str> { + self.property(&ICalendarProperty::Jsid) + .and_then(|e| e.values.first()) + .and_then(|v| v.as_text()) + } + + pub fn property(&self, prop: &ICalendarProperty) -> Option<&ICalendarEntry> { + self.entries.iter().find(|entry| &entry.name == prop) + } + + pub fn property_mut(&mut self, prop: &ICalendarProperty) -> Option<&mut ICalendarEntry> { + self.entries.iter_mut().find(|entry| &entry.name == prop) + } + + pub fn has_property(&self, prop: &ICalendarProperty) -> bool { + self.entries.iter().any(|entry| &entry.name == prop) + } + + pub fn properties<'x, 'y: 'x>( + &'x self, + prop: &'y ICalendarProperty, + ) -> impl Iterator + 'x { + self.entries.iter().filter(move |entry| &entry.name == prop) + } + + pub fn properties_mut<'x, 'y: 'x>( + &'x mut self, + prop: &'y ICalendarProperty, + ) -> impl Iterator + 'x { + self.entries + .iter_mut() + .filter(move |entry| &entry.name == prop) + } + + pub fn size(&self) -> usize { + self.entries.iter().map(|entry| entry.size()).sum() + } + + pub fn is_recurrent(&self) -> bool { + self.entries.iter().any(|entry| { + matches!( + entry.name, + ICalendarProperty::Rrule | ICalendarProperty::Rdate + ) + }) + } + + pub fn is_recurrence_override(&self) -> bool { + self.entries + .iter() + .any(|entry| matches!(entry.name, ICalendarProperty::RecurrenceId)) + } + + pub fn is_recurrent_or_override(&self) -> bool { + self.entries.iter().any(|entry| { + matches!( + entry.name, + ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::RecurrenceId + ) + }) + } + + pub fn status(&self) -> Option<&ICalendarStatus> { + self.entries + .iter() + .find_map(|entry| match (&entry.name, entry.values.first()) { + (ICalendarProperty::Status, Some(ICalendarValue::Status(status))) => Some(status), + _ => None, + }) + } + + pub fn transparency(&self) -> Option<&ICalendarTransparency> { + self.entries + .iter() + .find_map(|entry| match (&entry.name, entry.values.first()) { + (ICalendarProperty::Transp, Some(ICalendarValue::Transparency(trans))) => { + Some(trans) + } + _ => None, + }) + } +} + +impl ICalendarValue { + pub fn size(&self) -> usize { + match self { + ICalendarValue::Binary(value) => value.len(), + ICalendarValue::Text(value) => value.len(), + ICalendarValue::PartialDateTime(_) => std::mem::size_of::(), + ICalendarValue::RecurrenceRule(_) => std::mem::size_of::(), + _ => std::mem::size_of::(), + } + } + + pub fn as_text(&self) -> Option<&str> { + match self { + ICalendarValue::Text(s) => Some(s.as_str()), + ICalendarValue::Uri(v) => v.as_str(), + ICalendarValue::CalendarScale(v) => Some(v.as_str()), + ICalendarValue::Method(v) => Some(v.as_str()), + ICalendarValue::Classification(v) => Some(v.as_str()), + ICalendarValue::Status(v) => Some(v.as_str()), + ICalendarValue::Transparency(v) => Some(v.as_str()), + ICalendarValue::Action(v) => Some(v.as_str()), + ICalendarValue::BusyType(v) => Some(v.as_str()), + ICalendarValue::ParticipantType(v) => Some(v.as_str()), + ICalendarValue::ResourceType(v) => Some(v.as_str()), + ICalendarValue::Proximity(v) => Some(v.as_str()), + _ => None, + } + } + + pub fn into_text(self) -> Option> { + match self { + ICalendarValue::Text(s) => Some(Cow::Owned(s)), + ICalendarValue::Uri(v) => Some(Cow::Owned(v.into_unwrapped_string())), + ICalendarValue::CalendarScale(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Method(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Classification(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Status(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Transparency(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Action(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::BusyType(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::ParticipantType(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::ResourceType(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Proximity(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarValue::Integer(i) => Some(Cow::Owned(i.to_string())), + ICalendarValue::Float(f) => Some(Cow::Owned(f.to_string())), + ICalendarValue::Boolean(b) => Some(Cow::Borrowed(if b { "TRUE" } else { "FALSE" })), + ICalendarValue::PartialDateTime(dt) => dt.to_rfc3339().map(Cow::Owned), + ICalendarValue::RecurrenceRule(rrule) => Some(Cow::Owned(rrule.to_string())), + ICalendarValue::Binary(value) => String::from_utf8(value).ok().map(Cow::Owned), + ICalendarValue::Duration(value) => Some(Cow::Owned(value.to_string())), + ICalendarValue::Period(value) => Some(Cow::Owned(value.to_string())), + } + } + + pub fn into_partial_date_time(self) -> Option> { + match self { + ICalendarValue::PartialDateTime(dt) => Some(dt), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + ICalendarValue::Integer(i) => Some(*i), + _ => None, + } + } + + pub fn as_float(&self) -> Option { + match self { + ICalendarValue::Float(f) => Some(*f), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option { + match self { + ICalendarValue::Boolean(b) => Some(*b), + _ => None, + } + } + + pub fn as_partial_date_time(&self) -> Option<&PartialDateTime> { + match self { + ICalendarValue::PartialDateTime(dt) => Some(dt), + _ => None, + } + } + + pub fn as_binary(&self) -> Option<&[u8]> { + match self { + ICalendarValue::Binary(d) => Some(d.as_slice()), + _ => None, + } + } +} + +impl ICalendarEntry { + #[inline] + pub fn parameters( + &self, + prop: &ICalendarParameterName, + ) -> impl Iterator { + self.params.iter().filter_map(move |param| { + if ¶m.name == prop { + Some(¶m.value) + } else { + None + } + }) + } + + #[inline] + pub fn parameter(&self, prop: &ICalendarParameterName) -> Option<&ICalendarParameterValue> { + self.params.iter().find_map(move |param| { + if ¶m.name == prop { + Some(¶m.value) + } else { + None + } + }) + } + + #[inline] + pub fn has_parameter(&self, prop: &ICalendarParameterName) -> bool { + self.params.iter().any(|param| ¶m.name == prop) + } + + pub fn jsid(&self) -> Option<&str> { + self.parameter(&ICalendarParameterName::Jsid) + .and_then(|v| v.as_text()) + } + + pub fn is_derived(&self) -> bool { + self.parameter(&ICalendarParameterName::Derived) + .is_some_and(|v| matches!(v, ICalendarParameterValue::Bool(true))) + } + + pub fn calendar_address(&self) -> Option<&str> { + self.values + .first() + .and_then(|v| v.as_text()) + .map(strip_mailto_scheme) + } + + pub fn size(&self) -> usize { + self.values.iter().map(|value| value.size()).sum::() + + self + .params + .iter() + .map(|param| std::mem::size_of::() + param.value.size()) + .sum::() + + self.name.as_str().len() + } +} + +impl ICalendarParameterValue { + pub fn as_text(&self) -> Option<&str> { + match self { + ICalendarParameterValue::Text(v) => Some(v.as_str()), + ICalendarParameterValue::Bool(v) => Some(if *v { "TRUE" } else { "FALSE" }), + ICalendarParameterValue::Uri(uri) => uri.as_str(), + ICalendarParameterValue::Cutype(v) => Some(v.as_str()), + ICalendarParameterValue::Fbtype(v) => Some(v.as_str()), + ICalendarParameterValue::Partstat(v) => Some(v.as_str()), + ICalendarParameterValue::Related(v) => Some(v.as_str()), + ICalendarParameterValue::Reltype(v) => Some(v.as_str()), + ICalendarParameterValue::Role(v) => Some(v.as_str()), + ICalendarParameterValue::ScheduleAgent(v) => Some(v.as_str()), + ICalendarParameterValue::ScheduleForceSend(v) => Some(v.as_str()), + ICalendarParameterValue::Value(v) => Some(v.as_str()), + ICalendarParameterValue::Display(v) => Some(v.as_str()), + ICalendarParameterValue::Feature(v) => Some(v.as_str()), + ICalendarParameterValue::Linkrel(v) => Some(v.as_str()), + ICalendarParameterValue::Duration(_) + | ICalendarParameterValue::Integer(_) + | ICalendarParameterValue::Null => None, + } + } + + pub fn into_text(self) -> Option> { + match self { + ICalendarParameterValue::Text(v) => Some(Cow::Owned(v)), + ICalendarParameterValue::Bool(v) => { + Some(Cow::Borrowed(if v { "TRUE" } else { "FALSE" })) + } + ICalendarParameterValue::Uri(uri) => Some(Cow::Owned(uri.into_unwrapped_string())), + ICalendarParameterValue::Cutype(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Fbtype(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Partstat(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Related(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Reltype(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Role(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::ScheduleAgent(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::ScheduleForceSend(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Value(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Display(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Feature(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Linkrel(v) => Some(Cow::Borrowed(v.as_str())), + ICalendarParameterValue::Duration(v) => Some(Cow::Owned(v.to_string())), + ICalendarParameterValue::Integer(v) => Some(Cow::Owned(v.to_string())), + ICalendarParameterValue::Null => None, + } + } + + pub fn into_value_type(self) -> Option> { + match self { + ICalendarParameterValue::Value(v) => Some(IanaType::Iana(v)), + ICalendarParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + ICalendarParameterValue::Integer(v) => Some(*v), + ICalendarParameterValue::Text(v) => v.parse().ok(), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + ICalendarParameterValue::Bool(v) => Some(*v), + _ => None, + } + } + + pub fn size(&self) -> usize { + match self { + ICalendarParameterValue::Text(v) => v.len(), + ICalendarParameterValue::Uri(v) => v.size(), + _ => std::mem::size_of::(), + } + } +} + +impl ICalendarComponentType { + pub fn has_time_ranges(&self) -> bool { + matches!( + self, + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VJournal + | ICalendarComponentType::VFreebusy + ) + } + + pub fn is_scheduling_object(&self) -> bool { + matches!( + self, + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VJournal + | ICalendarComponentType::VFreebusy + ) + } + + pub fn is_event(&self) -> bool { + matches!(self, ICalendarComponentType::VEvent) + } + + pub fn is_todo(&self) -> bool { + matches!(self, ICalendarComponentType::VTodo) + } + + pub fn is_event_or_todo(&self) -> bool { + matches!( + self, + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo + ) + } + + pub fn is_journal(&self) -> bool { + matches!(self, ICalendarComponentType::VJournal) + } + + pub fn is_freebusy(&self) -> bool { + matches!(self, ICalendarComponentType::VFreebusy) + } + + pub fn is_location(&self) -> bool { + matches!(self, ICalendarComponentType::VLocation) + } + + pub fn is_alarm(&self) -> bool { + matches!(self, ICalendarComponentType::VAlarm) + } + + pub fn is_participant(&self) -> bool { + matches!(self, ICalendarComponentType::Participant) + } +} + +impl Uri { + pub fn as_str(&self) -> Option<&str> { + match self { + Uri::Data(data) => data.content_type.as_deref(), + Uri::Location(loc) => loc.as_str().into(), + } + } + + pub fn size(&self) -> usize { + match self { + Uri::Data(data) => { + data.data.len() + data.content_type.as_ref().map(|s| s.len()).unwrap_or(0) + } + Uri::Location(loc) => loc.len(), + } + } +} + +impl ICalendarDuration { + pub fn from_seconds(seconds: i64) -> Self { + let mut secs = seconds; + let neg = secs < 0; + if neg { + secs = -secs; + } + let weeks = (secs / 604800) as u32; + secs %= 604800; + let days = (secs / 86400) as u32; + secs %= 86400; + let hours = (secs / 3600) as u32; + secs %= 3600; + let minutes = (secs / 60) as u32; + secs %= 60; + let seconds = secs as u32; + + Self { + weeks, + days, + hours, + minutes, + seconds, + neg, + } + } + + pub fn from_days(days: i64) -> Self { + let neg = days < 0; + let days = days.unsigned_abs(); + let (weeks, days) = if days.is_multiple_of(7) { + (days / 7, 0) + } else { + (0, days) + }; + + Self { + weeks: weeks as u32, + days: days as u32, + hours: 0, + minutes: 0, + seconds: 0, + neg, + } + } + + pub fn to_time_delta(&self) -> Option { + chrono::TimeDelta::new(self.as_seconds(), 0) + } + + pub fn as_seconds(&self) -> i64 { + let secs = self.seconds as i64 + + self.minutes as i64 * 60 + + self.hours as i64 * 3600 + + self.days as i64 * 86400 + + self.weeks as i64 * 604800; + + if self.neg { -secs } else { secs } + } + + pub fn is_empty(&self) -> bool { + self.weeks == 0 + && self.days == 0 + && self.hours == 0 + && self.minutes == 0 + && self.seconds == 0 + } +} diff --git a/vendor/calcard/src/icalendar/writer.rs b/vendor/calcard/src/icalendar/writer.rs new file mode 100644 index 00000000..121c5d74 --- /dev/null +++ b/vendor/calcard/src/icalendar/writer.rs @@ -0,0 +1,587 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + ICalendar, ICalendarDay, ICalendarDuration, ICalendarEntry, ICalendarPeriod, + ICalendarRecurrenceRule, ICalendarValueType, +}; +use crate::{ + common::{ + CalendarScale, IanaString, PartialDateTime, + writer::{FoldingWriter, LineWriter, write_bytes, write_param_value, write_text}, + }, + icalendar::{ + ICalendarMonth, ICalendarParameterName, ICalendarParameterValue, ICalendarValue, Uri, + ValueSeparator, + }, +}; +use std::{ + fmt::{Display, Write}, + slice::Iter, +}; + +impl ICalendar { + pub fn write_to(&self, out: &mut impl Write) -> std::fmt::Result { + let mut component_iter: Iter<'_, u32> = [0].iter(); + let mut component_stack = Vec::with_capacity(4); + + loop { + if let Some(component_id) = component_iter.next() { + let component = self.components.get(*component_id as usize).unwrap(); + write_boundary(out, "BEGIN:", component.component_type.as_str())?; + + for entry in &component.entries { + entry.write_to(out)?; + } + + if !component.component_ids.is_empty() { + component_stack.push((component, component_iter)); + component_iter = component.component_ids.iter(); + } else { + write_boundary(out, "END:", component.component_type.as_str())?; + } + } else if let Some((component, iter)) = component_stack.pop() { + write_boundary(out, "END:", component.component_type.as_str())?; + component_iter = iter; + } else { + break; + } + } + + Ok(()) + } +} + +impl ICalendarEntry { + pub fn write_to(&self, out: &mut impl Write) -> std::fmt::Result { + let mut folded = FoldingWriter::new(out); + let out = &mut folded; + + out.write_atomic(self.name.as_str())?; + + if matches!(self.values.first(), Some(ICalendarValue::Binary(_))) { + out.write_atomic(";ENCODING=BASE64")?; + } + + let mut types = None; + let mut last_param: Option<&ICalendarParameterName> = None; + + for param in &self.params { + if last_param.is_some_and(|last_param| last_param == ¶m.name) { + out.write_atomic(",")?; + } else { + out.write_atomic(";")?; + out.write_atomic(param.name.as_str())?; + if !matches!(param.value, ICalendarParameterValue::Null) { + out.write_atomic("=")?; + } + last_param = Some(¶m.name); + } + + match ¶m.value { + ICalendarParameterValue::Text(v) => { + write_param_value(out, v)?; + } + ICalendarParameterValue::Integer(i) => { + write!(out, "{i}")?; + } + ICalendarParameterValue::Bool(v) => { + let v = if !matches!(param.name, ICalendarParameterName::Range) { + if *v { "TRUE" } else { "FALSE" } + } else { + "THISANDFUTURE" + }; + out.write_atomic(v)?; + } + ICalendarParameterValue::Uri(uri) => { + out.write_atomic("\"")?; + write_uri(out, uri, false)?; + out.write_atomic("\"")?; + } + ICalendarParameterValue::Cutype(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Fbtype(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Partstat(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Related(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Reltype(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Role(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::ScheduleAgent(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::ScheduleForceSend(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Value(v) => { + types = Some(v); + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Display(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Feature(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Duration(v) => { + write!(out, "{v}")?; + } + ICalendarParameterValue::Linkrel(v) => { + write_param_value(out, v.as_str())?; + } + ICalendarParameterValue::Null => { + last_param = None; + } + } + } + + out.write_atomic(":")?; + + let (default_type, separator) = self.name.default_types(); + let separator = if !matches!(separator, ValueSeparator::Comma) { + ";" + } else { + "," + }; + let default_type = default_type.unwrap_ical(); + + for (pos, value) in self.values.iter().enumerate() { + if pos > 0 { + out.write_atomic(separator)?; + } + + let text = match value { + ICalendarValue::Binary(v) => { + write_bytes(out, v)?; + continue; + } + ICalendarValue::Boolean(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + continue; + } + ICalendarValue::Uri(v) => { + write_uri(out, v, true)?; + continue; + } + ICalendarValue::PartialDateTime(v) => { + v.format_as_ical(out, types.unwrap_or(&default_type))?; + continue; + } + ICalendarValue::Duration(v) => { + write!(out, "{}", v)?; + continue; + } + ICalendarValue::RecurrenceRule(v) => { + write!(out, "{}", v)?; + continue; + } + ICalendarValue::Period(v) => { + write!(out, "{}", v)?; + continue; + } + ICalendarValue::Float(v) => { + write!(out, "{v}")?; + continue; + } + ICalendarValue::Integer(v) => { + write!(out, "{v}")?; + continue; + } + ICalendarValue::Text(v) => { + let escape = + !matches!(types.unwrap_or(&default_type), ICalendarValueType::Recur); + write_text(out, v, escape, escape)?; + continue; + } + ICalendarValue::CalendarScale(v) => v.as_str(), + ICalendarValue::Method(v) => v.as_str(), + ICalendarValue::Classification(v) => v.as_str(), + ICalendarValue::Status(v) => v.as_str(), + ICalendarValue::Transparency(v) => v.as_str(), + ICalendarValue::Action(v) => v.as_str(), + ICalendarValue::BusyType(v) => v.as_str(), + ICalendarValue::ParticipantType(v) => v.as_str(), + ICalendarValue::ResourceType(v) => v.as_str(), + ICalendarValue::Proximity(v) => v.as_str(), + }; + + out.write_atomic(text)?; + } + + out.end_line() + } +} + +pub(crate) fn write_uri( + out: &mut FoldingWriter<'_, W>, + value: &Uri, + escape: bool, +) -> std::fmt::Result { + match value { + Uri::Data(v) => { + let media_type = v.content_type.as_deref().unwrap_or_default(); + out.write_str("data:")?; + out.write_str(media_type)?; + out.write_str(";")?; + if escape { + out.write_atomic("base64\\,")?; + } else { + out.write_atomic("base64,")?; + } + write_bytes(out, &v.data) + } + Uri::Location(v) => write_text(out, v, escape, escape), + } +} + +fn write_boundary(out: &mut impl Write, keyword: &str, name: &str) -> std::fmt::Result { + let mut folded = FoldingWriter::new(out); + folded.write_atomic(keyword)?; + folded.write_atomic(name)?; + folded.end_line() +} + +#[cfg(feature = "rkyv")] +pub(crate) fn write_component_begin(out: &mut impl Write, name: &str) -> std::fmt::Result { + write_boundary(out, "BEGIN:", name) +} + +#[cfg(feature = "rkyv")] +pub(crate) fn write_component_end(out: &mut impl Write, name: &str) -> std::fmt::Result { + write_boundary(out, "END:", name) +} + +impl Uri { + pub fn to_unwrapped_string(&self) -> String { + match self { + Uri::Data(v) => v.to_unwrapped_string(), + Uri::Location(v) => v.to_string(), + } + } + + pub fn into_unwrapped_string(self) -> String { + match self { + Uri::Data(v) => v.to_unwrapped_string(), + Uri::Location(v) => v, + } + } +} + +impl Display for ICalendarRecurrenceRule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FREQ={}", self.freq.as_str())?; + if let Some(until) = &self.until { + write!(f, ";UNTIL=")?; + until.format_as_ical( + f, + if until.has_date_and_time() { + &ICalendarValueType::DateTime + } else { + &ICalendarValueType::Date + }, + )?; + } + if let Some(count) = self.count.filter(|c| *c > 0) { + write!(f, ";COUNT={}", count)?; + } + if let Some(interval) = self.interval { + write!(f, ";INTERVAL={}", interval)?; + } + if !self.bysecond.is_empty() { + write!(f, ";BYSECOND=")?; + for (pos, item) in self.bysecond.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byminute.is_empty() { + write!(f, ";BYMINUTE=")?; + for (pos, item) in self.byminute.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byhour.is_empty() { + write!(f, ";BYHOUR=")?; + for (pos, item) in self.byhour.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byday.is_empty() { + write!(f, ";BYDAY=")?; + for (pos, item) in self.byday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bymonthday.is_empty() { + write!(f, ";BYMONTHDAY=")?; + for (pos, item) in self.bymonthday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byyearday.is_empty() { + write!(f, ";BYYEARDAY=")?; + for (pos, item) in self.byyearday.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.byweekno.is_empty() { + write!(f, ";BYWEEKNO=")?; + for (pos, item) in self.byweekno.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bymonth.is_empty() { + write!(f, ";BYMONTH=")?; + for (pos, item) in self.bymonth.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if !self.bysetpos.is_empty() { + write!(f, ";BYSETPOS=")?; + for (pos, item) in self.bysetpos.iter().enumerate() { + if pos > 0 { + write!(f, ",")?; + } + write!(f, "{}", item)?; + } + } + if let Some(wkst) = self.wkst { + write!(f, ";WKST={}", wkst.as_str())?; + } + if let Some(rscale) = &self.rscale { + write!(f, ";RSCALE={}", rscale.as_str())?; + } else if self.skip.is_some() { + write!(f, ";RSCALE={}", CalendarScale::Gregorian.as_str())?; + } + if let Some(skip) = &self.skip { + write!(f, ";SKIP={}", skip.as_str())?; + } + + Ok(()) + } +} + +impl Display for ICalendarDay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(ordwk) = self.ordwk { + write!(f, "{}", ordwk)?; + } + write!(f, "{}", self.weekday.as_str()) + } +} + +impl Display for ICalendarMonth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.is_leap() { + write!(f, "{}", self.month()) + } else { + write!(f, "{}L", self.month()) + } + } +} + +impl Display for ICalendarPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ICalendarPeriod::Range { start, end } => { + start.format_as_ical(f, &ICalendarValueType::DateTime)?; + write!(f, "/")?; + end.format_as_ical(f, &ICalendarValueType::DateTime) + } + ICalendarPeriod::Duration { start, duration } => { + start.format_as_ical(f, &ICalendarValueType::DateTime)?; + write!(f, "/{}", duration) + } + } + } +} + +impl Display for ICalendarDuration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.neg { + write!(f, "-")?; + } + write!(f, "P")?; + if self.is_empty() { + return write!(f, "T0S"); + } + if self.weeks != 0 { + write!(f, "{}W", self.weeks)?; + } + if self.days != 0 { + write!(f, "{}D", self.days)?; + } + if self.hours != 0 || self.minutes != 0 || self.seconds != 0 { + write!(f, "T")?; + if self.hours != 0 { + write!(f, "{}H", self.hours)?; + } + if self.minutes != 0 { + write!(f, "{}M", self.minutes)?; + } + if self.seconds != 0 { + write!(f, "{}S", self.seconds)?; + } + } + + Ok(()) + } +} + +impl PartialDateTime { + pub fn format_as_ical( + &self, + out: &mut impl Write, + fmt: &ICalendarValueType, + ) -> std::fmt::Result { + if matches!(fmt, ICalendarValueType::Date | ICalendarValueType::DateTime) { + write!( + out, + "{:04}{:02}{:02}", + self.year.unwrap_or_default(), + self.month.unwrap_or_default(), + self.day.unwrap_or_default() + )?; + } + + if matches!(fmt, ICalendarValueType::DateTime) { + write!(out, "T")?; + } + + if matches!(fmt, ICalendarValueType::DateTime | ICalendarValueType::Time) { + write!( + out, + "{:02}{:02}{:02}", + self.hour.unwrap_or_default(), + self.minute.unwrap_or_default(), + self.second.unwrap_or_default() + )?; + + if matches!((self.tz_hour, self.tz_minute), (Some(0), Some(0))) { + write!(out, "Z")?; + } + } + + if matches!(fmt, ICalendarValueType::UtcOffset) { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + + write!( + out, + "{:02}{:02}", + self.tz_hour.unwrap_or_default(), + self.tz_minute.unwrap_or_default(), + )?; + } + + Ok(()) + } +} + +impl Display for ICalendar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.write_to(f) + } +} + +#[cfg(test)] +mod tests { + use crate::{Entry, Parser, icalendar::ICalendar}; + + fn parse(input: &str) -> ICalendar { + let mut parser = Parser::new(input); + let Entry::ICalendar(ical) = parser.entry() else { + panic!("expected iCalendar for {input}"); + }; + ical + } + + fn write(ical: &ICalendar) -> String { + let out = ical.to_string(); + + #[cfg(feature = "rkyv")] + { + let bytes = rkyv::to_bytes::(ical).unwrap(); + let archived = + rkyv::access::(&bytes) + .unwrap(); + assert_eq!(out, archived.to_string(), "archived writer diverged"); + } + + out + } + + #[test] + fn test_write_fold_width() { + for input in [ + "RRULE:FREQ=WEEKLY;UNTIL=20210309T080000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR,SA;WKST=MO", + "RDATE;VALUE=PERIOD:19970101T180000Z/19970102T070000Z,19970109T180000Z/PT5H30M", + "FREEBUSY;FBTYPE=BUSY:20120103T091500Z/20120103T101500Z,20120113T130000Z/20120113T150000Z", + "EXDATE;TZID=US/Central:20170706T090000,20170713T090000,20170720T090000,20170803T090000", + "DTSTART;TZID=/softwarestudio.org/Olson_20011030_5/America/Chicago:20030515T183000", + "REQUEST-STATUS:EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE;;", + ] { + let ical = parse(&format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ + BEGIN:VEVENT\r\nUID:1\r\n{input}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + )); + + let out = write(&ical); + crate::common::writer::assert_fold_width(&out, input); + + assert_eq!(write(&parse(&out)), out, "not idempotent for {input}"); + } + } + + #[test] + fn test_write_component_boundary_fold_width() { + let name = "X".repeat(90); + let ical = parse(&format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ + BEGIN:{name}\r\nEND:{name}\r\nEND:VCALENDAR\r\n" + )); + + let out = write(&ical); + crate::common::writer::assert_fold_width(&out, &name); + + assert_eq!(write(&parse(&out)), out, "not idempotent for {name}"); + } +} diff --git a/vendor/calcard/src/jscalendar/export/convert.rs b/vendor/calcard/src/jscalendar/export/convert.rs new file mode 100644 index 00000000..64e9169e --- /dev/null +++ b/vendor/calcard/src/jscalendar/export/convert.rs @@ -0,0 +1,2392 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{ + IanaParse, PartialDateTime, + timezone::{Tz, TzTimestamp}, + }, + icalendar::*, + jscalendar::{ + export::{ConvertedComponent, State}, + *, + }, +}; +use ahash::{AHashMap, AHashSet}; +use chrono::{TimeDelta, TimeZone}; +use jmap_tools::{JsonPointer, JsonPointerHandler, JsonPointerItem, Key, Map, Value}; +use std::str::FromStr; + +impl<'x, I: JSCalendarId, B: JSCalendarId> JSCalendar<'x, I, B> { + pub fn into_icalendar(self) -> Option { + self.0.into_object().map(|entries| { + let mut ical = ICalendar::default(); + + ical.from_jscalendar( + State { + tz: Default::default(), + tz_end: Default::default(), + tz_rid: Default::default(), + start: Default::default(), + recurrence_id: Default::default(), + uid: Default::default(), + entries, + is_date: false, + default_component_type: ICalendarComponentType::VCalendar, + }, + None, + ); + + ical + }) + } + + pub fn into_inner(self) -> Value<'x, JSCalendarProperty, JSCalendarValue> { + self.0 + } +} + +impl ICalendar { + #[allow(clippy::wrong_self_convention)] + pub(super) fn from_jscalendar( + &mut self, + mut state: State<'_, I, B>, + mut parent_component: Option<&mut ICalendarComponent>, + ) { + let mut root_conversions = None; + let mut locale = None; + let mut uid = state.uid.map(Cow::Borrowed); + let mut organizer_address = None; + let mut organizer_params: Vec = Vec::new(); + let mut organizer_sent_by = None; + let mut privacy = None; + let mut organizer_participant_id = None; + let mut has_organizer_participant = false; + let mut main_location_id = None; + let mut start = None; + let mut component_type = None; + let mut description_content_type = None; + let mut overrides = None; + let mut is_show_without_time = false; + let mut has_time_component = false; + let mut has_time_zone = false; + + for (key, value) in state.entries.as_mut_vec() { + match (key, value) { + (Key::Property(JSCalendarProperty::ICalendar), Value::Object(obj)) => { + root_conversions = + ConvertedComponent::try_from_object(std::mem::take(obj.as_mut_vec())); + } + (Key::Property(JSCalendarProperty::MainLocationId), Value::Str(text)) => { + main_location_id = Some(std::mem::take(text)); + } + (Key::Property(JSCalendarProperty::TimeZone), Value::Str(text)) => { + state.tz = Tz::from_str(text.as_ref()).ok(); + has_time_zone = true; + } + (Key::Property(JSCalendarProperty::EndTimeZone), Value::Str(text)) => { + state.tz_end = Tz::from_str(text.as_ref()).ok(); + } + (Key::Property(JSCalendarProperty::RecurrenceIdTimeZone), Value::Str(text)) => { + state.tz_rid = Tz::from_str(text.as_ref()).ok(); + } + (Key::Property(JSCalendarProperty::Uid), Value::Str(text)) => { + uid = Some(std::mem::take(text)); + } + (Key::Property(JSCalendarProperty::Locale), Value::Str(text)) => { + locale = Some(std::mem::take(text)); + } + (Key::Property(JSCalendarProperty::OrganizerCalendarAddress), Value::Str(text)) => { + organizer_address = Some(std::mem::take(text)); + } + (Key::Property(JSCalendarProperty::SentBy), Value::Str(text)) => { + organizer_sent_by = Some(std::mem::take(text)); + } + ( + Key::Property(JSCalendarProperty::Privacy), + Value::Element(JSCalendarValue::Privacy(value)), + ) => { + privacy = Some(*value); + } + (Key::Property(JSCalendarProperty::Privacy), Value::Str(_)) => { + privacy = Some(JSCalendarPrivacy::Private); + } + (Key::Property(JSCalendarProperty::DescriptionContentType), Value::Str(text)) => { + description_content_type = Some(std::mem::take(text)); + } + ( + Key::Property(JSCalendarProperty::Start), + Value::Element(JSCalendarValue::DateTime(dt)), + ) if matches!( + state.default_component_type, + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo + ) => + { + has_time_component |= !dt.is_start_of_day(); + start = dt.to_naive_date_time(); + } + ( + Key::Property(JSCalendarProperty::Due | JSCalendarProperty::RecurrenceId), + Value::Element(JSCalendarValue::DateTime(dt)), + ) => { + has_time_component |= !dt.is_start_of_day(); + } + ( + Key::Property( + JSCalendarProperty::Duration | JSCalendarProperty::EstimatedDuration, + ), + Value::Element(JSCalendarValue::Duration(duration)), + ) => { + has_time_component |= + duration.hours != 0 || duration.minutes != 0 || duration.seconds != 0; + } + (Key::Property(JSCalendarProperty::ShowWithoutTime), Value::Bool(value)) => { + is_show_without_time = *value; + } + (Key::Property(JSCalendarProperty::RecurrenceRule), Value::Object(obj)) => { + if let Some(Value::Element(JSCalendarValue::DateTime(dt))) = + obj.get(&Key::Property(JSCalendarProperty::Until)) + { + has_time_component |= !dt.is_start_of_day(); + } + } + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(typ)), + ) => { + component_type = typ.to_icalendar_component_type(); + } + (Key::Property(JSCalendarProperty::RecurrenceOverrides), Value::Object(obj)) + if matches!( + state.default_component_type, + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo + ) => + { + has_time_component |= obj.iter().any(|(key, _)| { + matches!(key, Key::Property(JSCalendarProperty::DateTime(dt)) + if !dt.is_start_of_day()) + }); + overrides = Some(std::mem::take(obj)); + } + _ => (), + } + } + + // Apply override patch objects + if let Some(overrides) = &mut overrides { + for (_, value) in overrides.as_mut_vec().iter_mut() { + let Value::Object(obj) = value else { + continue; + }; + + let patch_count = obj.len(); + obj.as_mut_vec().retain(|(key, value)| match key { + Key::Property(JSCalendarProperty::Pointer(pointer)) => { + !JSCalendarProperty::is_forbidden_override_pointer(pointer) + } + Key::Property(JSCalendarProperty::Privacy) => { + let override_privacy = match value { + Value::Element(JSCalendarValue::Privacy(value)) => *value, + _ => JSCalendarPrivacy::Private, + }; + if override_privacy > privacy.unwrap_or(JSCalendarPrivacy::Public) { + privacy = Some(override_privacy); + } + false + } + Key::Property(property) => !property.is_forbidden_override_patch(), + _ => true, + }); + if obj.is_empty() && patch_count > 0 { + *value = Value::Null; + continue; + } + + let mut patched_obj = None; + + for (key, value) in obj.as_mut_vec() { + if let Key::Property(JSCalendarProperty::Pointer(ptr)) = key { + patched_obj + .get_or_insert_with(|| Value::Object(state.entries.clone())) + .patch_jptr(ptr.iter(), std::mem::take(value)); + } + } + + if let Some(patched_obj) = patched_obj { + let mut patched_props: AHashMap< + Key<'static, JSCalendarProperty>, + Vec>>, + > = AHashMap::new(); + + for (key, value) in std::mem::take(obj.as_mut_vec()) { + if let Key::Property(JSCalendarProperty::Pointer(ptr)) = key { + let mut ptr = ptr.into_iter(); + if let ( + Some(JsonPointerItem::Key(key)), + Some(JsonPointerItem::Key(subkey)), + ) = (ptr.next(), ptr.next()) + { + patched_props.entry(key).or_default().push(subkey); + } + } else { + obj.insert_unchecked(key, value); + } + } + + for (key, patched_value) in patched_obj.into_expanded_object() { + if let Some(keys) = patched_props.get(&key) { + let Value::Object(obj) = + obj.insert_or_get_mut(key, Value::Object(Map::default())) + else { + continue; + }; + for (key, value) in patched_value.into_expanded_object() { + if keys.contains(&key) { + obj.insert_unchecked(key, value); + } + } + } + } + } + } + } + + /* + In the iCalendar component, the value data types of the DTSTART, DUE and + RECURRENCE-ID properties all MUST be of the same form, either DATE, or DATE WITH + LOCAL TIME, or DATE WITH UTC TIME. It MUST be DATE if the "showWithoutTime" + property value is "true", the "timeZone" property value is not set, and the time + component is zero in the values of the "start", "due", "duration", + "estimatedDuration", "recurrenceId" properties, and the RecurrenceRule object's + "until" property, and in any key of the "recurrenceOverrides" property value. + */ + + state.is_date |= is_show_without_time && !has_time_zone && !has_time_component; + + // Build component + let mut component = + ICalendarComponent::new(component_type.unwrap_or(state.default_component_type)); + if parent_component.is_none() { + debug_assert!(self.components.is_empty()); + self.components + .push(ICalendarComponent::new(ICalendarComponentType::VCalendar)); + } + + // Add start date + if let Some(start) = start.and_then(|dt| { + state + .tz + .unwrap_or_default() + .from_local_datetime(&dt) + .single() + }) { + state.start = Some(start); + } + if let Some(dt) = state.start { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Dtstart) + .import_converted(&[JSCalendarProperty::Start], &mut root_conversions) + .with_date(dt, state.is_date), + ); + } + + // Add UID + if let Some(uid) = &uid { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Uid) + .import_converted(&[JSCalendarProperty::Uid], &mut root_conversions) + .with_value(uid.clone().into_owned()), + ); + } + + let mut show_without_time = None; + let mut add_recurrence_id = state.recurrence_id.is_some(); + for (key, value) in state.entries.into_vec() { + let Key::Property(property) = key else { + component.insert_jsprop(&[key.to_string().as_ref()], value); + continue; + }; + + match (&property, value, &component.component_type) { + ( + JSCalendarProperty::Links, + Value::Object(obj), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + component.import_links(obj, &mut root_conversions); + } + ( + JSCalendarProperty::Participants, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + for (name, value) in obj.into_vec() { + let Value::Object(mut value) = value else { + continue; + }; + + let mut item_conversions = ConvertedComponent::build(&mut value); + let mut entry = ICalendarEntry::new(ICalendarProperty::Attendee); + let mut participant = + ICalendarComponent::new(ICalendarComponentType::Participant); + let mut participant_name = None; + let mut calendar_address = None; + let mut status = None; + let mut progress = None; + let mut description = None; + let mut description_content_type = None; + let mut is_uuid5_key = false; + + for (sub_property, value) in value.into_vec() { + match (sub_property, value) { + ( + Key::Property(JSCalendarProperty::CalendarAddress), + Value::Str(text), + ) => { + is_uuid5_key = uuid5(text.as_ref()) == name.to_string(); + calendar_address = Some(text); + } + ( + Key::Property(JSCalendarProperty::DelegatedFrom), + Value::Str(text), + ) => { + entry.params.push(ICalendarParameter::delegated_from( + Uri::parse(text.into_owned()), + )); + } + ( + Key::Property(JSCalendarProperty::DelegatedTo), + Value::Str(text), + ) => { + entry.params.push(ICalendarParameter::delegated_to( + Uri::parse(text.into_owned()), + )); + } + (Key::Property(JSCalendarProperty::Email), Value::Str(text)) => { + entry.params.push(ICalendarParameter::email(Uri::parse( + text.into_owned(), + ))); + } + ( + Key::Property(JSCalendarProperty::ExpectReply), + Value::Bool(value), + ) => { + entry.params.push(ICalendarParameter::rsvp(value)); + } + ( + Key::Property(JSCalendarProperty::Kind), + Value::Element(JSCalendarValue::ParticipantKind(kind)), + ) => { + entry.params.push(ICalendarParameter::cutype(match kind { + JSCalendarParticipantKind::Individual => { + ICalendarUserTypes::Individual + } + JSCalendarParticipantKind::Group => { + ICalendarUserTypes::Group + } + JSCalendarParticipantKind::Resource => { + ICalendarUserTypes::Resource + } + JSCalendarParticipantKind::Location => { + ICalendarUserTypes::Room + } + })); + } + (Key::Property(JSCalendarProperty::Kind), Value::Str(text)) => { + entry + .params + .push(ICalendarParameter::cutype(text.to_lowercase())); + } + ( + Key::Property(JSCalendarProperty::MemberOf), + Value::Object(obj), + ) => { + for key in obj.into_expanded_boolean_set() { + entry.params.push(ICalendarParameter::member(Uri::parse( + key.into_string(), + ))); + } + } + (Key::Property(JSCalendarProperty::Name), Value::Str(text)) => { + participant_name = Some(text); + } + ( + Key::Property(JSCalendarProperty::ParticipationStatus), + Value::Element(JSCalendarValue::ParticipationStatus(status_)), + ) => { + status = Some(status_); + } + ( + Key::Property(JSCalendarProperty::Progress), + Value::Element(JSCalendarValue::Progress(progress_)), + ) => { + progress = Some(progress_); + } + (Key::Property(JSCalendarProperty::Roles), Value::Object(obj)) => { + for key in obj.into_expanded_boolean_set() { + match key { + Key::Property(JSCalendarProperty::ParticipantRole( + role, + )) => { + let role = match role { + JSCalendarParticipantRole::Optional => { + ICalendarParticipationRole::OptParticipant + } + JSCalendarParticipantRole::Informational => { + ICalendarParticipationRole::NonParticipant + } + JSCalendarParticipantRole::Chair => { + ICalendarParticipationRole::Chair + } + JSCalendarParticipantRole::Required => { + ICalendarParticipationRole::ReqParticipant + } + JSCalendarParticipantRole::Owner => { + ICalendarParticipationRole::Owner + } + }; + entry.params.push(ICalendarParameter::role(role)); + } + key => { + component.insert_jsprop::( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + JSCalendarProperty::Roles:: + .to_string() + .as_ref(), + key.to_string().as_ref(), + ], + Value::Bool(true), + ); + } + } + } + } + (Key::Property(JSCalendarProperty::SentBy), Value::Str(text)) => { + entry.params.push(ICalendarParameter::sent_by(Uri::parse( + text.into_owned(), + ))); + } + ( + Key::Property(JSCalendarProperty::Description), + Value::Str(text), + ) => { + description = Some(text); + } + ( + Key::Property(JSCalendarProperty::DescriptionContentType), + Value::Str(text), + ) => { + description_content_type = Some(text); + } + (Key::Property(JSCalendarProperty::Links), Value::Object(obj)) => { + participant.import_links(obj, &mut item_conversions); + } + ( + Key::Property(JSCalendarProperty::PercentComplete), + Value::Number(number), + ) => { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::PercentComplete) + .with_value(number.cast_to_i64()) + .import_converted( + &[JSCalendarProperty::PercentComplete], + &mut item_conversions, + ), + ); + } + ( + Key::Property( + JSCalendarProperty::Type | JSCalendarProperty::ICalendar, + ), + _, + ) => {} + (sub_property, value) => { + if item_conversions.is_none() { + component.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } else { + participant.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + } + + match (description, description_content_type) { + (Some(description), Some(content_type)) => { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::StyledDescription) + .with_param(ICalendarParameter::fmttype( + content_type.into_owned(), + )) + .with_value(description.into_owned()) + .import_converted( + &[JSCalendarProperty::Description], + &mut item_conversions, + ), + ); + } + (Some(description), None) => { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::Description) + .with_value(description.into_owned()) + .import_converted( + &[JSCalendarProperty::Description], + &mut item_conversions, + ), + ); + } + _ => {} + } + + match (status, progress) { + ( + Some(JSCalendarParticipationStatus::Accepted) | None, + Some(progress), + ) => { + let partstat = match progress { + JSCalendarProgress::NeedsAction => { + Some(ICalendarParticipationStatus::NeedsAction) + } + JSCalendarProgress::InProcess => { + Some(ICalendarParticipationStatus::InProcess) + } + JSCalendarProgress::Completed => { + Some(ICalendarParticipationStatus::Completed) + } + JSCalendarProgress::Failed => { + Some(ICalendarParticipationStatus::Failed) + } + // Not a valid Participant progress value per section 4.4.5 + JSCalendarProgress::Cancelled => None, + }; + if let Some(partstat) = partstat { + entry.params.push(ICalendarParameter::partstat(partstat)); + } + } + (Some(status), _) => { + entry + .params + .push(ICalendarParameter::partstat(match status { + JSCalendarParticipationStatus::Accepted => { + ICalendarParticipationStatus::Accepted + } + JSCalendarParticipationStatus::Declined => { + ICalendarParticipationStatus::Declined + } + JSCalendarParticipationStatus::NeedsAction => { + ICalendarParticipationStatus::NeedsAction + } + JSCalendarParticipationStatus::Tentative => { + ICalendarParticipationStatus::Tentative + } + JSCalendarParticipationStatus::Delegated => { + ICalendarParticipationStatus::Delegated + } + })); + } + _ => {} + } + + let has_component = + !participant.entries.is_empty() || item_conversions.is_some(); + let has_entry = !entry.params.is_empty() || item_conversions.is_none(); + let is_organizer = organizer_address.is_some() + && organizer_address == calendar_address + && !has_organizer_participant; + has_organizer_participant |= is_organizer; + + /* + The "owner" role needs not convert to the ROLE parameter of the + ATTENDEE property if the Participant object converts to both the + ATTENDEE and ORGANIZER property. + */ + + if is_organizer { + entry.params.retain(|param| { + !matches!( + (¶m.name, ¶m.value), + ( + ICalendarParameterName::Role, + ICalendarParameterValue::Role( + ICalendarParticipationRole::Owner + ) + ) + ) + }); + } + + if let Some(calendar_address) = calendar_address { + let calendar_address = + ICalendarValue::Uri(Uri::parse(calendar_address.into_owned())); + + if has_component { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::CalendarAddress) + .with_value(calendar_address.clone()), + ); + } + if has_entry { + entry.values.push(calendar_address); + } + } + + if let Some(participant_name) = participant_name { + if has_component { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::Summary) + .with_value(participant_name.as_ref().to_string()) + .import_converted( + &[JSCalendarProperty::Name], + &mut item_conversions, + ), + ); + } + if has_entry { + entry + .params + .push(ICalendarParameter::cn(participant_name.into_owned())); + } + } + + debug_assert!(has_component || !entry.values.is_empty()); + + /* + The Participant object converts to an ATTENDEE property and PARTICIPANT + component, unless it is fully represented by the ORGANIZER property: + its "calendarAddress" property value matches the + "organizerCalendarAddress" property value, it does not have any role + but the "owner" role set, and it only has properties set that convert + to ORGANIZER parameters. + */ + + let is_organizer_only = is_organizer + && !has_component + && entry.params.iter().all(|param| { + matches!( + param.name, + ICalendarParameterName::Cn + | ICalendarParameterName::Email + | ICalendarParameterName::SentBy + ) + }); + + let participant_id = name.into_string(); + + if is_organizer_only { + organizer_params.append(&mut entry.params); + if !is_uuid5_key { + organizer_params + .push(ICalendarParameter::jsid(participant_id.clone())); + } + organizer_participant_id = Some(participant_id.clone()); + } else if is_organizer { + organizer_params.extend( + entry + .params + .iter() + .filter(|param| { + matches!( + param.name, + ICalendarParameterName::Cn + | ICalendarParameterName::Email + | ICalendarParameterName::SentBy + ) + }) + .cloned(), + ); + } + + if has_component { + if !is_uuid5_key { + participant.entries.push( + ICalendarEntry::new(ICalendarProperty::Jsid) + .with_value(participant_id.clone()), + ); + } + if let Some(item_conversions) = item_conversions { + participant = item_conversions.apply_conversions(participant, self); + } + component + .component_ids + .push(self.push_component(participant)); + } + + if !entry.values.is_empty() && !is_organizer_only { + let mut attendee = entry.import_converted_with_id( + &[JSCalendarProperty::Participants], + &mut root_conversions, + Some(participant_id.as_str()), + ); + if !is_uuid5_key { + attendee.add_param(ICalendarParameter::jsid(participant_id)); + } + + component.entries.push(attendee); + } + } + } + ( + JSCalendarProperty::Alerts, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let mut alerts = Vec::new(); + let mut has_related_to = false; + + for (name, value) in obj.into_vec() { + let Value::Object(mut value) = value else { + continue; + }; + + let mut item_conversions = ConvertedComponent::build(&mut value); + let mut alert = ICalendarComponent::new(ICalendarComponentType::VAlarm); + + for (sub_property, value) in value.into_vec() { + match (sub_property, value) { + ( + Key::Property( + JSCalendarProperty::Type | JSCalendarProperty::ICalendar, + ), + _, + ) => {} + ( + Key::Property(JSCalendarProperty::Acknowledged), + Value::Element(JSCalendarValue::DateTime(dt)), + ) => { + alert.entries.push( + ICalendarEntry::new(ICalendarProperty::Acknowledged) + .with_value(PartialDateTime::from_utc_timestamp( + dt.timestamp, + )) + .import_converted( + &[JSCalendarProperty::Acknowledged], + &mut item_conversions, + ), + ); + } + ( + Key::Property(JSCalendarProperty::Action), + Value::Element(JSCalendarValue::AlertAction(action)), + ) => { + alert.entries.push( + ICalendarEntry::new(ICalendarProperty::Action) + .with_value(ICalendarValue::Action(match action { + JSCalendarAlertAction::Display => { + ICalendarAction::Display + } + JSCalendarAlertAction::Email => { + ICalendarAction::Email + } + })) + .import_converted( + &[JSCalendarProperty::Action], + &mut item_conversions, + ), + ); + } + ( + Key::Property(JSCalendarProperty::RelatedTo), + Value::Object(obj), + ) => { + has_related_to = true; + alert.import_relations(obj, &mut item_conversions); + } + ( + Key::Property(JSCalendarProperty::Trigger), + Value::Object(obj), + ) => { + let mut offset = None; + let mut rel_to = None; + let mut when = None; + + for (key, value) in obj.into_vec() { + match (key, value) { + ( + Key::Property(JSCalendarProperty::Offset), + Value::Element(JSCalendarValue::Duration(value)), + ) => { + offset = Some(value); + } + ( + Key::Property(JSCalendarProperty::RelativeTo), + Value::Element(JSCalendarValue::RelativeTo(value)), + ) => { + rel_to = Some(value); + } + ( + Key::Property(JSCalendarProperty::When), + Value::Element(JSCalendarValue::DateTime(value)), + ) => { + when = Some(value); + } + (Key::Property(JSCalendarProperty::Type), _) => {} + (key, value) => { + alert.insert_jsprop( + &[ + JSCalendarProperty::Trigger:: + .to_string() + .as_ref(), + key.to_string().as_ref(), + ], + value, + ); + } + } + } + + if let Some(when) = when { + alert.entries.push( + ICalendarEntry::new(ICalendarProperty::Trigger) + .with_param(ICalendarParameter::value( + ICalendarValueType::DateTime, + )) + .with_value(PartialDateTime::from_utc_timestamp( + when.timestamp, + )) + .import_converted( + &[ + JSCalendarProperty::Trigger, + JSCalendarProperty::When, + ], + &mut item_conversions, + ), + ); + } else if let Some(offset) = offset { + alert.entries.push( + ICalendarEntry::new(ICalendarProperty::Trigger) + .with_param_opt(rel_to.map(|rel_to| { + ICalendarParameter::related(match rel_to { + JSCalendarRelativeTo::Start => { + ICalendarRelated::Start + } + JSCalendarRelativeTo::End => { + ICalendarRelated::End + } + }) + })) + .with_value(offset) + .import_converted( + &[ + JSCalendarProperty::Trigger, + JSCalendarProperty::Offset, + ], + &mut item_conversions, + ), + ); + } + } + (sub_property, value) => { + alert + .insert_jsprop(&[sub_property.to_string().as_ref()], value); + } + } + } + + if let Some(item_conversions) = item_conversions { + alert = item_conversions.apply_conversions(alert, self); + } + + if !alert.entries.is_empty() { + alert.entries.push( + ICalendarEntry::new(ICalendarProperty::Jsid) + .with_value(name.to_string().into_owned()), + ); + alerts.push(alert); + } + } + + // Map RELATED-TO properties + let mut alert_mappings = Vec::new(); + if has_related_to { + alert_mappings = alerts + .iter() + .filter_map(|alert| { + Some((alert.jsid()?.to_string(), alert.uid()?.to_string())) + }) + .collect(); + } + for mut alert in alerts { + if has_related_to { + for related_to in alert.properties_mut(&ICalendarProperty::RelatedTo) { + if let Some(ICalendarValue::Text(related_to)) = + related_to.values.first_mut() + && let Some((_, uid)) = + alert_mappings.iter().find(|(jsid, _)| jsid == related_to) + { + *related_to = uid.clone(); + } + } + } + component.component_ids.push(self.push_component(alert)); + } + } + ( + JSCalendarProperty::Keywords, + Value::Object(obj), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Categories) + .with_values( + obj.into_expanded_boolean_set() + .map(|v| ICalendarValue::Text(v.into_string())) + .collect(), + ) + .import_converted( + &[JSCalendarProperty::Keywords], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Privacy, + Value::Element(JSCalendarValue::Privacy(_)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => {} + ( + JSCalendarProperty::Color, + Value::Str(text), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Color) + .with_value(text.into_owned()) + .import_converted(&[JSCalendarProperty::Color], &mut root_conversions), + ); + } + ( + JSCalendarProperty::Categories, + Value::Object(obj), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + for value in obj.into_expanded_boolean_set() { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Concept) + .with_value(ICalendarValue::Text(value.into_string())) + .import_converted( + &[JSCalendarProperty::Categories], + &mut root_conversions, + ), + ); + } + } + ( + JSCalendarProperty::VirtualLocations, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + for (name, value) in obj.into_vec() { + let mut entry = ICalendarEntry::new(ICalendarProperty::Conference); + let mut is_uuid5_key = false; + + for (sub_property, value) in value.into_expanded_object() { + match (sub_property, value) { + ( + Key::Property(JSCalendarProperty::Features), + Value::Object(obj), + ) => { + for key in obj.into_expanded_boolean_set() { + let value = match key { + Key::Property( + JSCalendarProperty::VirtualLocationFeature(feature), + ) => ICalendarParameterValue::Feature(match feature { + JSCalendarVirtualLocationFeature::Audio => { + ICalendarFeatureType::Audio + } + JSCalendarVirtualLocationFeature::Chat => { + ICalendarFeatureType::Chat + } + JSCalendarVirtualLocationFeature::Feed => { + ICalendarFeatureType::Feed + } + JSCalendarVirtualLocationFeature::Moderator => { + ICalendarFeatureType::Moderator + } + JSCalendarVirtualLocationFeature::Phone => { + ICalendarFeatureType::Phone + } + JSCalendarVirtualLocationFeature::Screen => { + ICalendarFeatureType::Screen + } + JSCalendarVirtualLocationFeature::Video => { + ICalendarFeatureType::Video + } + }), + other => ICalendarParameterValue::Text( + other.to_string().into_owned(), + ), + }; + entry.params.push(ICalendarParameter::feature(value)); + } + } + (Key::Property(JSCalendarProperty::Name), Value::Str(text)) => { + entry + .params + .push(ICalendarParameter::label(text.into_owned())); + } + (Key::Property(JSCalendarProperty::Uri), Value::Str(text)) => { + is_uuid5_key = uuid5(text.as_ref()) == name.to_string(); + entry + .values + .push(ICalendarValue::Uri(Uri::parse(text.into_owned()))); + } + ( + Key::Property( + JSCalendarProperty::Type | JSCalendarProperty::ICalendar, + ), + _, + ) => {} + (sub_property, value) => { + component.insert_jsprop( + &[ + JSCalendarProperty::VirtualLocations:: + .to_string() + .as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + if !is_uuid5_key { + entry.add_param(ICalendarParameter::jsid(name.into_string())); + } + + component.entries.push(entry.import_converted( + &[JSCalendarProperty::VirtualLocations], + &mut root_conversions, + )); + } + } + ( + JSCalendarProperty::Title, + Value::Str(text), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Summary) + .with_param_opt( + locale + .as_ref() + .map(|locale| ICalendarParameter::language(locale.to_string())), + ) + .with_value(text.into_owned()) + .import_converted(&[JSCalendarProperty::Title], &mut root_conversions), + ); + } + ( + JSCalendarProperty::Title, + Value::Str(text), + ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Name) + .with_param_opt( + locale + .as_ref() + .map(|locale| ICalendarParameter::language(locale.to_string())), + ) + .with_value(text.into_owned()) + .import_converted(&[JSCalendarProperty::Title], &mut root_conversions), + ); + } + ( + JSCalendarProperty::Locations, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let has_multi_location = obj.len() > 1; + for (name, value) in obj.into_vec() { + let Value::Object(mut value) = value else { + continue; + }; + let mut item_conversions = ConvertedComponent::build(&mut value); + let is_main_location = main_location_id + .as_ref() + .is_some_and(|l| l == &name.to_string()); + let mut is_uuid5_key = false; + let mut location = (item_conversions + .as_ref() + .is_some_and(|v| v.name.is_location()) + || value.iter().any(|(k, v)| match (k, v) { + ( + Key::Property( + JSCalendarProperty::Links + | JSCalendarProperty::LocationTypes + | JSCalendarProperty::Description, + ), + _, + ) => true, + ( + Key::Property(JSCalendarProperty::Coordinates), + Value::Str(uri), + ) => uri.strip_prefix("geo:").is_none_or(|v| { + !v.as_bytes() + .iter() + .all(|b| matches!(b, b'0'..=b'9' | b'.' | b',' | b'-')) + }), + _ => false, + }) + || (has_multi_location && !is_main_location)) + .then_some(ICalendarComponent::new(ICalendarComponentType::VLocation)); + + for (sub_property, value) in value.into_vec() { + match (sub_property, value) { + ( + Key::Property(JSCalendarProperty::LocationTypes), + Value::Object(obj), + ) => { + if let Some(location) = &mut location { + location.entries.push( + ICalendarEntry::new(ICalendarProperty::LocationType) + .import_converted( + &[JSCalendarProperty::LocationTypes], + &mut item_conversions, + ) + .with_values( + obj.into_expanded_boolean_set() + .map(|v| { + ICalendarValue::Text(v.into_string()) + }) + .collect(), + ), + ); + } + } + (Key::Property(JSCalendarProperty::Name), Value::Str(text)) => { + is_uuid5_key |= uuid5(text.as_ref()) == name.to_string(); + + if let Some(location) = &mut location { + location.entries.push( + ICalendarEntry::new(ICalendarProperty::Name) + .import_converted( + &[JSCalendarProperty::Name], + &mut item_conversions, + ) + .with_value(text.clone().into_owned()), + ); + } + + if location.is_none() || is_main_location { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Location) + .with_param_opt((!is_uuid5_key).then(|| { + ICalendarParameter::jsid( + name.clone().into_string(), + ) + })) + .with_param_opt( + (has_multi_location) + .then(|| ICalendarParameter::derived(true)), + ) + .with_value(text.into_owned()) + .import_converted( + &[JSCalendarProperty::Locations], + &mut root_conversions, + ), + ); + } + } + ( + Key::Property(JSCalendarProperty::Description), + Value::Str(text), + ) => { + if let Some(location) = &mut location { + location.entries.push( + ICalendarEntry::new(ICalendarProperty::Description) + .import_converted( + &[JSCalendarProperty::Description], + &mut item_conversions, + ) + .with_value(text.clone().into_owned()), + ); + } + } + ( + Key::Property(JSCalendarProperty::Coordinates), + Value::Str(text), + ) => { + is_uuid5_key |= uuid5(text.as_ref()) == name.to_string(); + if let Some(location) = &mut location { + let entry = + ICalendarEntry::new(ICalendarProperty::Coordinates) + .import_converted( + &[JSCalendarProperty::Coordinates], + &mut item_conversions, + ); + location.entries.push( + if entry.name == ICalendarProperty::Geo { + entry.with_values(parse_geo(text)) + } else { + entry.with_value(Uri::parse(text.into_owned())) + }, + ); + } else { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Geo) + .with_param_opt((!is_uuid5_key).then(|| { + ICalendarParameter::jsid( + name.clone().into_string(), + ) + })) + .with_values(parse_geo(text)) + .import_converted( + &[JSCalendarProperty::Locations], + &mut root_conversions, + ), + ); + } + } + (Key::Property(JSCalendarProperty::Links), Value::Object(obj)) => { + if let Some(location) = &mut location { + location.import_links(obj, &mut item_conversions); + } + } + ( + Key::Property( + JSCalendarProperty::Type | JSCalendarProperty::ICalendar, + ), + _, + ) => {} + (sub_property, value) => { + if let Some(location) = &mut location { + location.insert_jsprop( + &[sub_property.to_string().as_ref()], + value, + ); + } else { + component.insert_jsprop( + &[ + JSCalendarProperty::Locations:: + .to_string() + .as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + } + + if let Some(mut location) = location { + if !is_uuid5_key { + location.entries.push( + ICalendarEntry::new(ICalendarProperty::Jsid) + .with_value(name.to_string().into_owned()), + ); + } + if let Some(item_conversions) = item_conversions { + location = item_conversions.apply_conversions(location, self); + } + component.component_ids.push(self.push_component(location)); + } + } + } + ( + JSCalendarProperty::Created, + Value::Element(JSCalendarValue::DateTime(dt)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Created) + .with_value(PartialDateTime::from_utc_timestamp(dt.timestamp)) + .import_converted( + &[JSCalendarProperty::Created], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Description, + Value::Str(text), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + if let Some(description_content_type) = description_content_type.take() { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::StyledDescription) + .with_value(text.into_owned()) + .with_param(ICalendarParameter::fmttype( + description_content_type.into_owned(), + )) + .import_converted( + &[JSCalendarProperty::Description], + &mut root_conversions, + ), + ); + } else { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Description) + .with_value(text.into_owned()) + .import_converted( + &[JSCalendarProperty::Description], + &mut root_conversions, + ), + ); + } + } + ( + JSCalendarProperty::RecurrenceRule, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let mut rrule = ICalendarRecurrenceRule::default(); + + for (key, value) in obj.into_vec() { + let Key::Property(key) = key else { + continue; + }; + match (key, value) { + ( + JSCalendarProperty::Frequency, + Value::Element(JSCalendarValue::Frequency(value)), + ) => { + rrule.freq = value; + } + ( + JSCalendarProperty::Until, + Value::Element(JSCalendarValue::DateTime(value)), + ) => { + rrule.until = value + .to_naive_date_time() + .and_then(|dt| { + state + .tz + .unwrap_or_default() + .from_local_datetime(&dt) + .single() + }) + .map(|dt| { + if state.is_date { + PartialDateTime::from_date_timestamp( + dt.to_naive_timestamp(), + ) + } else { + PartialDateTime::from_utc_timestamp(dt.timestamp()) + } + }); + } + (JSCalendarProperty::Count, Value::Number(value)) => { + rrule.count = Some(value.cast_to_u64() as u32); + } + (JSCalendarProperty::Interval, Value::Number(value)) => { + rrule.interval = Some(value.cast_to_u64() as u16); + } + (JSCalendarProperty::BySecond, Value::Array(value)) => { + rrule.bysecond = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| u8::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByMinute, Value::Array(value)) => { + rrule.byminute = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| u8::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByHour, Value::Array(value)) => { + rrule.byhour = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| u8::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByDay, Value::Array(value)) => { + for item in value { + let mut weekday = None; + let mut ordwk = None; + + for (key, value) in item.into_expanded_object() { + match (key, value) { + ( + Key::Property(JSCalendarProperty::Day), + Value::Element(JSCalendarValue::Weekday(value)), + ) => { + weekday = Some(value); + } + ( + Key::Property(JSCalendarProperty::NthOfPeriod), + Value::Number(value), + ) => { + ordwk = i16::try_from(value.cast_to_i64()).ok(); + } + _ => {} + } + } + + if let Some(weekday) = weekday { + rrule.byday.push(ICalendarDay { weekday, ordwk }); + } + } + } + (JSCalendarProperty::ByMonthDay, Value::Array(value)) => { + rrule.bymonthday = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| i8::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByYearDay, Value::Array(value)) => { + rrule.byyearday = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| i16::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByWeekNo, Value::Array(value)) => { + rrule.byweekno = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| i8::try_from(n).ok())) + .collect(); + } + (JSCalendarProperty::ByMonth, Value::Array(value)) => { + rrule.bymonth = value + .into_iter() + .filter_map(|v| { + v.as_str().and_then(|v| ICalendarMonth::parse(v.as_bytes())) + }) + .collect(); + } + (JSCalendarProperty::BySetPosition, Value::Array(value)) => { + rrule.bysetpos = value + .into_iter() + .filter_map(|v| v.as_i64().and_then(|n| i32::try_from(n).ok())) + .collect(); + } + ( + JSCalendarProperty::FirstDayOfWeek, + Value::Element(JSCalendarValue::Weekday(value)), + ) => { + rrule.wkst = Some(value); + } + ( + JSCalendarProperty::Rscale, + Value::Element(JSCalendarValue::CalendarScale(value)), + ) => { + rrule.rscale = Some(value); + } + ( + JSCalendarProperty::Skip, + Value::Element(JSCalendarValue::Skip(value)), + ) => { + rrule.skip = Some(value); + } + (JSCalendarProperty::Type | JSCalendarProperty::ICalendar, _) => {} + (key, value) => { + component.insert_jsprop( + &[ + JSCalendarProperty::RecurrenceRule:: + .to_string() + .as_ref(), + key.to_string().as_ref(), + ], + value, + ); + } + } + } + + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Rrule) + .with_value(rrule) + .import_converted( + &[JSCalendarProperty::RecurrenceRule], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Updated, + Value::Element(JSCalendarValue::DateTime(dt)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Dtstamp) + .with_value(PartialDateTime::from_utc_timestamp(dt.timestamp)) + .import_converted( + &[JSCalendarProperty::Updated], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Updated, + Value::Element(JSCalendarValue::DateTime(dt)), + ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::LastModified) + .with_value(PartialDateTime::from_utc_timestamp(dt.timestamp)) + .import_converted( + &[JSCalendarProperty::Updated], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Due, + Value::Element(JSCalendarValue::DateTime(dt)), + ICalendarComponentType::VTodo, + ) => { + if let Some(dt) = dt.to_naive_date_time().and_then(|dt| { + state + .tz + .unwrap_or_default() + .from_local_datetime(&dt) + .single() + }) { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Due) + .import_converted(&[JSCalendarProperty::Due], &mut root_conversions) + .with_date(dt, state.is_date), + ); + } + } + ( + JSCalendarProperty::RecurrenceId, + Value::Element(JSCalendarValue::DateTime(dt)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + if let Some(dt) = dt.to_naive_date_time().and_then(|dt| { + state + .tz_rid + .or(state.tz) + .unwrap_or_default() + .from_local_datetime(&dt) + .single() + }) { + add_recurrence_id = false; + component.entries.push( + ICalendarEntry::new(ICalendarProperty::RecurrenceId) + .import_converted( + &[JSCalendarProperty::RecurrenceId], + &mut root_conversions, + ) + .with_date(dt, state.is_date), + ); + } + } + ( + JSCalendarProperty::Duration, + Value::Element(JSCalendarValue::Duration(duration)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let entry = ICalendarEntry::new(if state.tz_end.is_none() { + ICalendarProperty::Duration + } else { + ICalendarProperty::Dtend + }) + .import_converted(&[JSCalendarProperty::Duration], &mut root_conversions); + if entry.name == ICalendarProperty::Dtend { + if let Some(end) = state.start.and_then(|start| { + start.checked_add_signed(TimeDelta::seconds(duration.as_seconds())) + }) { + component.entries.push( + entry.with_date( + state + .tz_end + .map(|tz_end| end.with_timezone(&tz_end)) + .unwrap_or(end), + state.is_date, + ), + ); + } else { + component.insert_jsprop::( + &[property.to_string().as_ref()], + Value::Element(JSCalendarValue::Duration(duration)), + ); + } + } else { + component.entries.push(entry.with_value(duration)); + } + } + ( + JSCalendarProperty::EstimatedDuration, + Value::Element(JSCalendarValue::Duration(duration)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::EstimatedDuration) + .with_value(duration) + .import_converted( + &[JSCalendarProperty::EstimatedDuration], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Method, + Value::Element(JSCalendarValue::Method(method)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + if let Some(parent_component) = &mut parent_component + && !parent_component.has_property(&ICalendarProperty::Method) + { + parent_component.entries.push( + ICalendarEntry::new(ICalendarProperty::Method).with_value(method), + ); + } + } + ( + JSCalendarProperty::PercentComplete, + Value::Number(number), + ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::PercentComplete) + .with_value(number.cast_to_i64()) + .import_converted( + &[JSCalendarProperty::PercentComplete], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Priority, + Value::Number(number), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Priority) + .with_value(number.cast_to_i64()) + .import_converted( + &[JSCalendarProperty::Priority], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Sequence, + Value::Number(number), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Sequence) + .with_value(number.cast_to_i64()) + .import_converted( + &[JSCalendarProperty::Sequence], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::ProdId, + Value::Str(text), + ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Prodid) + .with_value(text.into_owned()) + .import_converted(&[JSCalendarProperty::ProdId], &mut root_conversions), + ); + } + ( + JSCalendarProperty::RelatedTo, + Value::Object(obj), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.import_relations(obj, &mut root_conversions); + } + ( + JSCalendarProperty::ShowWithoutTime, + Value::Bool(value), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + show_without_time = Some( + ICalendarEntry::new(ICalendarProperty::ShowWithoutTime) + .with_value(value) + .import_converted( + &[JSCalendarProperty::ShowWithoutTime], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Progress, + Value::Element(JSCalendarValue::Progress(value)), + ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Status) + .with_value(match value { + JSCalendarProgress::NeedsAction => ICalendarStatus::NeedsAction, + JSCalendarProgress::InProcess => ICalendarStatus::InProcess, + JSCalendarProgress::Completed => ICalendarStatus::Completed, + JSCalendarProgress::Failed => ICalendarStatus::Failed, + JSCalendarProgress::Cancelled => ICalendarStatus::Cancelled, + }) + .import_converted( + &[JSCalendarProperty::Progress], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Status, + Value::Element(JSCalendarValue::EventStatus(value)), + ICalendarComponentType::VEvent, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Status) + .with_value(match value { + JSCalendarEventStatus::Confirmed => ICalendarStatus::Confirmed, + JSCalendarEventStatus::Cancelled => ICalendarStatus::Cancelled, + JSCalendarEventStatus::Tentative => ICalendarStatus::Tentative, + }) + .import_converted(&[JSCalendarProperty::Status], &mut root_conversions), + ); + } + ( + JSCalendarProperty::Status | JSCalendarProperty::Progress, + Value::Str(text), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Status) + .with_value( + ICalendarStatus::parse(text.as_bytes()) + .map(ICalendarValue::Status) + .unwrap_or_else(|| ICalendarValue::Text(text.into_owned())), + ) + .import_converted(&[JSCalendarProperty::Status], &mut root_conversions), + ); + } + ( + JSCalendarProperty::Source, + Value::Str(text), + ICalendarComponentType::VCalendar, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Source) + .with_value(Uri::parse(text.into_owned())) + .import_converted(&[JSCalendarProperty::Source], &mut root_conversions), + ); + } + ( + JSCalendarProperty::FreeBusyStatus, + Value::Element(JSCalendarValue::FreeBusyStatus(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Transp) + .with_value(match value { + JSCalendarFreeBusyStatus::Free => { + ICalendarTransparency::Transparent + } + JSCalendarFreeBusyStatus::Busy => ICalendarTransparency::Opaque, + }) + .import_converted( + &[JSCalendarProperty::FreeBusyStatus], + &mut root_conversions, + ), + ); + } + ( + JSCalendarProperty::Entries, + Value::Array(items), + ICalendarComponentType::VCalendar, + ) => { + for item in items { + if let Some(entries) = item.into_object() { + self.from_jscalendar( + State { + tz: None, + tz_end: None, + tz_rid: None, + start: None, + recurrence_id: None, + uid: None, + entries, + is_date: false, + default_component_type: ICalendarComponentType::VEvent, + }, + Some(&mut component), + ); + } + } + } + + // Skip previously processed properties + ( + JSCalendarProperty::Type + | JSCalendarProperty::ICalendar + | JSCalendarProperty::Uid + | JSCalendarProperty::MainLocationId + | JSCalendarProperty::Start + | JSCalendarProperty::TimeZone + | JSCalendarProperty::EndTimeZone + | JSCalendarProperty::RecurrenceIdTimeZone + | JSCalendarProperty::Locale + | JSCalendarProperty::OrganizerCalendarAddress + | JSCalendarProperty::SentBy + | JSCalendarProperty::DescriptionContentType + | JSCalendarProperty::Version + | JSCalendarProperty::RecurrenceOverrides, + _, + _, + ) + | (_, Value::Null, _) => {} + (property, value, _) => { + component.insert_jsprop(&[property.to_string().as_ref()], value); + } + } + } + + // Process recurrence overrides + if let Some(overrides) = overrides { + let mut exdates = Vec::new(); + let mut rdates = Vec::new(); + let converted_props = root_conversions.as_ref().map(|conv| { + conv.converted_props + .iter() + .filter_map(|(keys, _)| match (keys.first()?, keys.get(1)?) { + ( + Key::Property(JSCalendarProperty::RecurrenceOverrides), + Key::Property(JSCalendarProperty::DateTime(dt)), + ) => Some(*dt), + _ => None, + }) + .collect::>() + }); + + for (key, value) in overrides.into_vec() { + let (Key::Property(JSCalendarProperty::DateTime(jsdt)), Value::Object(mut obj)) = + (key, value) + else { + continue; + }; + let Some(dt) = jsdt.to_naive_date_time().and_then(|dt| { + state + .tz + .unwrap_or_default() + .from_local_datetime(&dt) + .single() + }) else { + continue; + }; + let has_converted_prop = converted_props + .as_ref() + .is_some_and(|props| props.contains(&jsdt)); + + // RDATE with value type PERIOD + if has_converted_prop + && obj.len() == 1 + && let Some(Value::Element(JSCalendarValue::Duration(duration))) = + obj.get(&Key::Property(JSCalendarProperty::Duration)) + { + let duration = duration.clone(); + let mut entry = ICalendarEntry::new(ICalendarProperty::Rdate) + .import_converted( + &[ + JSCalendarProperty::RecurrenceOverrides, + JSCalendarProperty::DateTime(jsdt), + ], + &mut root_conversions, + ) + .with_period(dt, duration); + entry.name = ICalendarProperty::Rdate; + component.entries.push(entry); + continue; + } + + if !obj.is_empty() { + if !obj.contains_key_value( + &Key::Property(JSCalendarProperty::Excluded), + &Value::Bool(true), + ) { + if let Some(privacy) = privacy { + obj.insert_unchecked( + Key::Property(JSCalendarProperty::Privacy), + Value::Element(JSCalendarValue::Privacy(privacy)), + ); + } + if let Some(address) = &organizer_address + && obj + .get(&Key::Property(JSCalendarProperty::Participants)) + .and_then(Value::as_object) + .is_some_and(|participants| { + participants.iter().any(|(_, participant)| { + matches!( + participant.as_object().and_then(|participant| { + participant.get(&Key::Property( + JSCalendarProperty::CalendarAddress, + )) + }), + Some(Value::Str(participant_address)) + if participant_address == address + ) + }) + }) + { + obj.insert_unchecked( + Key::Property(JSCalendarProperty::OrganizerCalendarAddress), + Value::Str(address.clone()), + ); + if let Some(sent_by) = &organizer_sent_by { + obj.insert_unchecked( + Key::Property(JSCalendarProperty::SentBy), + Value::Str(sent_by.clone()), + ); + } + } + self.from_jscalendar( + State { + tz: state.tz, + tz_end: state.tz_end, + tz_rid: state.tz_rid, + start: None, + recurrence_id: Some(dt), + entries: obj, + uid: uid.as_deref(), + is_date: state.is_date, + default_component_type: component.component_type.clone(), + }, + parent_component.as_deref_mut(), + ); + } else { + // EXDATE + if has_converted_prop { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Exdate) + .import_converted( + &[ + JSCalendarProperty::RecurrenceOverrides, + JSCalendarProperty::DateTime(jsdt), + ], + &mut root_conversions, + ) + .with_date(dt, state.is_date), + ); + } else { + exdates.push(dt); + } + } + } else { + // RDATE + if has_converted_prop { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Rdate) + .import_converted( + &[ + JSCalendarProperty::RecurrenceOverrides, + JSCalendarProperty::DateTime(jsdt), + ], + &mut root_conversions, + ) + .with_date(dt, state.is_date), + ); + } else { + rdates.push(dt) + } + } + } + + for (prop, values) in [ + (ICalendarProperty::Rdate, rdates), + (ICalendarProperty::Exdate, exdates), + ] { + if !values.is_empty() { + component + .entries + .push(ICalendarEntry::new(prop).with_dates(values, state.is_date)); + } + } + } + + // Add organizer, if not present in participants + if let Some(organizer_address) = organizer_address { + let mut organizer = ICalendarEntry::new(ICalendarProperty::Organizer); + if let Some(sent_by) = organizer_sent_by + && !organizer_params + .iter() + .any(|param| param.name == ICalendarParameterName::SentBy) + { + organizer_params.push(ICalendarParameter::sent_by(Uri::parse( + sent_by.into_owned(), + ))); + } + organizer.params = organizer_params; + organizer.values = vec![ICalendarValue::Text(organizer_address.into_owned())]; + + // Restore the parameters of Participant objects that only convert to ORGANIZER + if let Some(participant_id) = organizer_participant_id { + organizer = organizer.import_converted_with_id( + &[JSCalendarProperty::Participants], + &mut root_conversions, + Some(participant_id.as_str()), + ); + organizer.name = ICalendarProperty::Organizer; + } + + component.entries.push(organizer); + } + + // Add parent recurrence ID + if add_recurrence_id && let Some(dt) = state.recurrence_id { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::RecurrenceId) + .import_converted(&[JSCalendarProperty::RecurrenceId], &mut root_conversions) + .with_date(dt, state.is_date), + ); + } + + // Add showWithoutTime: true unless there are times with DATE types + if let Some(show_without_time) = show_without_time + && (!show_without_time.params.is_empty() + || matches!( + show_without_time.values.first(), + Some(ICalendarValue::Boolean(false)) + ) + || !component.entries.iter().any(|e| { + matches!( + e.name, + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Due + ) && matches!( + e.parameter(&ICalendarParameterName::Value), + Some(ICalendarParameterValue::Value(ICalendarValueType::Date)) + ) + })) + { + component.entries.push(show_without_time); + } + + if matches!( + component.component_type, + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo + ) { + let has_preserved_class = root_conversions.as_mut().is_some_and(|conversions| { + conversions.retain_class_property(matches!( + privacy, + None | Some(JSCalendarPrivacy::Private) + )) + }); + + if !has_preserved_class && let Some(privacy) = privacy { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Class) + .with_value(match privacy { + JSCalendarPrivacy::Public => ICalendarClassification::Public, + JSCalendarPrivacy::Private => ICalendarClassification::Private, + JSCalendarPrivacy::Secret => ICalendarClassification::Confidential, + }) + .import_converted(&[JSCalendarProperty::Privacy], &mut root_conversions), + ); + } + } + + if let Some(root_conversions) = root_conversions { + component = root_conversions.apply_conversions(component, self); + } + + // The VERSION property is required by RFC 5545 and has no JSCalendar counterpart + if component.component_type == ICalendarComponentType::VCalendar + && !component + .entries + .iter() + .any(|entry| entry.name == ICalendarProperty::Version) + { + component.entries.push( + ICalendarEntry::new(ICalendarProperty::Version).with_value("2.0".to_string()), + ); + } + + if let Some(parent_component) = parent_component { + parent_component + .component_ids + .push(self.push_component(component)); + } else { + self.components[0] = component; + } + } + + pub fn push_component(&mut self, component: ICalendarComponent) -> u32 { + let comp_num = self.components.len(); + self.components.push(component); + comp_num as u32 + } +} + +impl ICalendarComponent { + fn import_links( + &mut self, + obj: Map<'_, JSCalendarProperty, JSCalendarValue>, + conversion: &mut Option>, + ) { + let mut has_url = self + .entries + .iter() + .any(|entry| entry.name == ICalendarProperty::Url); + + for (name, value) in obj.into_vec() { + let mut entry = ICalendarEntry::new(ICalendarProperty::Link); + let mut link_rel = None; + let mut title = None; + let mut has_display = false; + let mut is_uuid5_key = false; + + for (sub_property, value) in value.into_expanded_object() { + match (sub_property, value) { + ( + Key::Property(JSCalendarProperty::Type | JSCalendarProperty::ICalendar), + _, + ) => {} + (Key::Property(JSCalendarProperty::Href), Value::Str(text)) => { + is_uuid5_key = uuid5(text.as_ref()) == name.to_string(); + entry + .values + .push(ICalendarValue::Uri(Uri::parse(text.into_owned()))); + } + (Key::Property(JSCalendarProperty::ContentType), Value::Str(text)) => { + entry + .params + .push(ICalendarParameter::fmttype(text.into_owned())); + } + (Key::Property(JSCalendarProperty::Size), Value::Number(number)) => { + entry + .params + .push(ICalendarParameter::size(number.cast_to_u64())); + } + ( + Key::Property(JSCalendarProperty::Rel), + Value::Element(JSCalendarValue::LinkRelation(relation)), + ) => { + link_rel = Some(relation); + } + (Key::Property(JSCalendarProperty::Display), Value::Object(obj)) => { + has_display = true; + for key in obj.into_expanded_boolean_set() { + let value = match key { + Key::Property(JSCalendarProperty::LinkDisplay(display)) => { + ICalendarParameterValue::Display(match display { + JSCalendarLinkDisplay::Badge => ICalendarDisplayType::Badge, + JSCalendarLinkDisplay::Graphic => { + ICalendarDisplayType::Graphic + } + JSCalendarLinkDisplay::Fullsize => { + ICalendarDisplayType::Fullsize + } + JSCalendarLinkDisplay::Thumbnail => { + ICalendarDisplayType::Thumbnail + } + }) + } + other => { + ICalendarParameterValue::Text(other.to_string().into_owned()) + } + }; + entry.params.push(ICalendarParameter::display(value)); + } + } + (Key::Property(JSCalendarProperty::Title), Value::Str(text)) => { + title = Some(text.into_owned()); + } + (sub_property, value) => { + self.insert_jsprop( + &[ + JSCalendarProperty::Links::.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + /* + Which iCalendar property to choose is implementation-specific. As a guideline, + stopping at the first matching criteria: + + 1. Choose ATTACH if the "rel" property value is "enclosure". + 2. Choose IMAGE if the "rel" property value is "icon". + 3. Choose URL if the "rel" property value is "describedby" and no URL property + is set in the iCalendar component. + 4. Choose LINK otherwise. + */ + + entry.name = match link_rel { + Some(LinkRelation::Enclosure) => ICalendarProperty::Attach, + Some(LinkRelation::Icon) => ICalendarProperty::Image, + Some(LinkRelation::Describedby) => ICalendarProperty::Url, + None if has_display => ICalendarProperty::Image, + _ => ICalendarProperty::Link, + }; + + let link_id = name.into_string(); + let mut entry = entry.import_converted_with_id( + &[JSCalendarProperty::Links], + conversion, + Some(link_id.as_str()), + ); + if !is_uuid5_key { + entry.add_param(ICalendarParameter::jsid(link_id)); + }; + + // At most one URL property is allowed per component + if entry.name == ICalendarProperty::Url { + if !has_url { + has_url = true; + } else { + entry.name = ICalendarProperty::Link; + } + } + + /* + The LINKREL parameter is not specified for the ATTACH, IMAGE and URL properties. + For interoperability, implementations SHOULD NOT set the LINKREL parameter on + these properties. + */ + + if let Some(relation) = link_rel + && entry.name == ICalendarProperty::Link + { + entry.add_param(ICalendarParameter::linkrel(relation)); + } + + /* + The "title" property converts to the FILENAME parameter for the ATTACH and IMAGE + properties. It converts to the LABEL parameter for the LINK and URL properties. + */ + + if let Some(title) = title { + entry.add_param( + if matches!( + entry.name, + ICalendarProperty::Attach | ICalendarProperty::Image + ) { + ICalendarParameter::filename(title) + } else { + ICalendarParameter::label(title) + }, + ); + } + + self.entries.push(entry); + } + } + + fn import_relations( + &mut self, + obj: Map<'_, JSCalendarProperty, JSCalendarValue>, + conversion: &mut Option>, + ) { + for (name, value) in obj.into_vec() { + let mut entry = ICalendarEntry::new(ICalendarProperty::RelatedTo); + + for (sub_property, value) in value.into_expanded_object() { + match (sub_property, value) { + (Key::Property(JSCalendarProperty::Relation), Value::Object(obj)) => { + for key in obj.into_expanded_boolean_set() { + let value = match key { + Key::Property(JSCalendarProperty::RelationValue(relation)) => { + ICalendarParameterValue::Reltype(match relation { + JSCalendarRelation::First => { + ICalendarRelationshipType::First + } + JSCalendarRelation::Next => ICalendarRelationshipType::Next, + JSCalendarRelation::Child => { + ICalendarRelationshipType::Child + } + JSCalendarRelation::Parent => { + ICalendarRelationshipType::Parent + } + JSCalendarRelation::Snooze => { + ICalendarRelationshipType::Snooze + } + }) + } + other => { + ICalendarParameterValue::Text(other.to_string().into_owned()) + } + }; + entry.params.push(ICalendarParameter::reltype(value)); + } + } + ( + Key::Property(JSCalendarProperty::Type | JSCalendarProperty::ICalendar), + _, + ) => {} + (sub_property, value) => { + self.insert_jsprop( + &[ + JSCalendarProperty::RelatedTo::.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + self.entries.push( + entry + .with_value(name.into_string()) + .import_converted(&[JSCalendarProperty::RelatedTo], conversion), + ); + } + } + + fn insert_jsprop( + &mut self, + path: &[&str], + value: Value<'_, JSCalendarProperty, JSCalendarValue>, + ) { + self.entries.push( + ICalendarEntry::new(ICalendarProperty::Jsprop) + .with_param(ICalendarParameter::jsptr(JsonPointer::< + JSCalendarProperty, + >::encode(path))) + .with_value(serde_json::to_string(&value).unwrap_or_default()), + ); + } +} + +fn parse_geo(text: Cow<'_, str>) -> Vec { + if let Some((a, b)) = text + .strip_prefix("geo:") + .and_then(|v| v.trim().split_once(',')) + .and_then(|(a, b)| { + let a = a.parse::().ok()?; + let b = b.parse::().ok()?; + Some((a, b)) + }) + { + vec![ICalendarValue::Float(a), ICalendarValue::Float(b)] + } else { + vec![ICalendarValue::Text(text.into_owned())] + } +} diff --git a/vendor/calcard/src/jscalendar/export/mod.rs b/vendor/calcard/src/jscalendar/export/mod.rs new file mode 100644 index 00000000..873ce317 --- /dev/null +++ b/vendor/calcard/src/jscalendar/export/mod.rs @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::timezone::Tz, + icalendar::ICalendarComponentType, + jscalendar::{JSCalendarId, JSCalendarProperty, JSCalendarValue}, +}; +use chrono::DateTime; +use jmap_tools::{Key, Map, Value}; + +pub mod convert; +pub mod params; +pub mod props; + +struct State<'x, I: JSCalendarId, B: JSCalendarId> { + entries: Map<'x, JSCalendarProperty, JSCalendarValue>, + default_component_type: ICalendarComponentType, + uid: Option<&'x str>, + tz: Option, + tz_end: Option, + tz_rid: Option, + start: Option>, + recurrence_id: Option>, + is_date: bool, +} + +#[allow(clippy::type_complexity)] +struct ConvertedComponent<'x, I: JSCalendarId, B: JSCalendarId> { + pub(super) name: ICalendarComponentType, + pub(super) converted_props: Vec<( + Vec>>, + Value<'x, JSCalendarProperty, JSCalendarValue>, + )>, + pub(super) converted_props_count: usize, + pub(super) properties: Vec, JSCalendarValue>>, + pub(super) components: Vec, JSCalendarValue>>, +} diff --git a/vendor/calcard/src/jscalendar/export/params.rs b/vendor/calcard/src/jscalendar/export/params.rs new file mode 100644 index 00000000..8d7276a1 --- /dev/null +++ b/vendor/calcard/src/jscalendar/export/params.rs @@ -0,0 +1,434 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{ + IanaParse, LinkRelation, PartialDateTime, + parser::Boolean, + timezone::{Tz, TzTimestamp}, + }, + icalendar::*, + jscalendar::{ + JSCalendarDateTime, JSCalendarId, JSCalendarProperty, JSCalendarValue, + export::ConvertedComponent, + }, + jscontact::export::params::ParamValue, +}; +use chrono::{DateTime, NaiveDateTime, TimeZone}; +use jmap_tools::{Key, Value}; +use std::str::FromStr; + +impl ICalendarEntry { + pub(super) fn import_converted( + self, + path: &[JSCalendarProperty], + conversions: &mut Option>, + ) -> Self { + self.import_converted_with_id(path, conversions, None) + } + + pub(super) fn import_converted_with_id( + mut self, + path: &[JSCalendarProperty], + conversions: &mut Option>, + id: Option<&str>, + ) -> Self { + let Some(conversions) = conversions + .as_mut() + .filter(|c| c.converted_props_count < c.converted_props.len()) + else { + return self; + }; + + // Obtain jsid + let value = self.values.first().and_then(|v| v.as_text()); + let jsid = if matches!(self.name, ICalendarProperty::RelatedTo) { + value + } else { + id.or_else(|| self.jsid()) + }; + + let mut matched_once = false; + + 'outer: for (keys, value) in conversions.converted_props.iter_mut() { + if matches!(value, Value::Null) { + continue; + } + + for (pos, item) in path.iter().enumerate() { + if !keys + .iter() + .any(|k| matches!(k, Key::Property(p) if p == item)) + { + if pos == 0 && matched_once { + // Array is sorted, so if we didn't match the first item, + // we won't match any further. + break 'outer; + } else { + continue 'outer; + } + } else { + matched_once = true; + } + } + + if jsid + .map(Key::Borrowed) + .is_none_or(|prop_id| keys.iter().any(|k| k == &prop_id)) + { + self.import_converted_properties(std::mem::take(value)); + conversions.converted_props_count += 1; + break; + } + } + + // Update binary contents + if self + .parameter(&ICalendarParameterName::Value) + .is_some_and(|v| { + matches!( + v, + ICalendarParameterValue::Value(ICalendarValueType::Binary) + ) + }) + && let Some(ICalendarValue::Uri(Uri::Data(data))) = self.values.first_mut() + { + let bin = ICalendarValue::Binary(std::mem::take(&mut data.data)); + if let Some(content_type) = data.content_type.take() + && !self.has_parameter(&ICalendarParameterName::Fmttype) + { + self.params.push(ICalendarParameter::fmttype(content_type)); + } + self.values[0] = bin; + } + + self + } + + pub(super) fn import_converted_properties( + &mut self, + props: Value<'_, JSCalendarProperty, JSCalendarValue>, + ) { + for (key, value) in props.into_expanded_object() { + match key { + Key::Property(JSCalendarProperty::Name) => { + if let Some(name) = value.into_string() { + self.name = ICalendarProperty::parse(name.as_bytes()) + .unwrap_or(ICalendarProperty::Other(name.to_ascii_uppercase())); + } + } + Key::Property(JSCalendarProperty::Parameters) => { + self.import_jcal_params(value); + } + _ => {} + } + } + } + + pub(super) fn import_jcal_params( + &mut self, + params: Value<'_, JSCalendarProperty, JSCalendarValue>, + ) { + for (key, value) in params.into_expanded_object() { + let mut values = match value { + Value::Array(values) => values.into_iter().filter_map(ParamValue::try_from_value), + value => vec![value] + .into_iter() + .filter_map(ParamValue::try_from_value), + } + .peekable(); + + if values.peek().is_none() { + continue; + } + + let key = key.to_string(); + let Some(param) = ICalendarParameterName::try_parse(key.as_bytes()) else { + let key = key.into_owned(); + + for value in values { + self.params.push(ICalendarParameter { + name: ICalendarParameterName::Other(key.to_ascii_uppercase()), + value: value.into_string().into_owned().into(), + }); + } + + continue; + }; + + for value in values { + let value = match ¶m { + ICalendarParameterName::Value => { + let value = value.into_string(); + ICalendarValueType::parse(value.as_bytes()) + .map(ICalendarParameterValue::Value) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Size | ICalendarParameterName::Order => { + match value.into_number() { + Ok(n) => ICalendarParameterValue::Integer(n.unsigned_abs()), + Err(value) => { + ICalendarParameterValue::Text(value.into_string().into_owned()) + } + } + } + ICalendarParameterName::Rsvp | ICalendarParameterName::Derived => { + let value = value.into_string(); + Boolean::parse(value.as_bytes()) + .map(|v| ICalendarParameterValue::Bool(v.0)) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Altrep + | ICalendarParameterName::DelegatedFrom + | ICalendarParameterName::DelegatedTo + | ICalendarParameterName::Dir + | ICalendarParameterName::Member + | ICalendarParameterName::SentBy + | ICalendarParameterName::Schema => { + ICalendarParameterValue::Uri(Uri::parse(value.into_string().into_owned())) + } + ICalendarParameterName::Range => { + let value = value.into_string(); + if value.eq_ignore_ascii_case("THISANDFUTURE") { + ICalendarParameterValue::Bool(true) + } else { + ICalendarParameterValue::Text(value.into_owned()) + } + } + ICalendarParameterName::Gap => { + let value = value.into_string(); + ICalendarDuration::parse(value.as_bytes()) + .map(ICalendarParameterValue::Duration) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Cutype => { + let value = value.into_string(); + ICalendarUserTypes::parse(value.as_bytes()) + .map(ICalendarParameterValue::Cutype) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Fbtype => { + let value = value.into_string(); + ICalendarFreeBusyType::parse(value.as_bytes()) + .map(ICalendarParameterValue::Fbtype) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Partstat => { + let value = value.into_string(); + ICalendarParticipationStatus::parse(value.as_bytes()) + .map(ICalendarParameterValue::Partstat) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Related => { + let value = value.into_string(); + ICalendarRelated::parse(value.as_bytes()) + .map(ICalendarParameterValue::Related) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Reltype => { + let value = value.into_string(); + ICalendarRelationshipType::parse(value.as_bytes()) + .map(ICalendarParameterValue::Reltype) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Role => { + let value = value.into_string(); + ICalendarParticipationRole::parse(value.as_bytes()) + .map(ICalendarParameterValue::Role) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::ScheduleAgent => { + let value = value.into_string(); + ICalendarScheduleAgentValue::parse(value.as_bytes()) + .map(ICalendarParameterValue::ScheduleAgent) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::ScheduleForceSend => { + let value = value.into_string(); + ICalendarScheduleForceSendValue::parse(value.as_bytes()) + .map(ICalendarParameterValue::ScheduleForceSend) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Display => { + let value = value.into_string(); + ICalendarDisplayType::parse(value.as_bytes()) + .map(ICalendarParameterValue::Display) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Feature => { + let value = value.into_string(); + ICalendarFeatureType::parse(value.as_bytes()) + .map(ICalendarParameterValue::Feature) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + ICalendarParameterName::Linkrel => { + let value = value.into_string(); + LinkRelation::parse(value.as_bytes()) + .map(ICalendarParameterValue::Linkrel) + .unwrap_or_else(|| ICalendarParameterValue::Text(value.into_owned())) + } + _ => ICalendarParameterValue::Text(value.into_string().into_owned()), + }; + + self.params.push(ICalendarParameter { + name: param.clone(), + value, + }); + } + } + } +} + +impl ICalendarEntry { + /* + A DATE value has no time and no timezone: the TZID parameter MUST NOT be applied to + it (Section 3.2.19 of [RFC5545]). + */ + + pub(super) fn with_date(mut self, dt: DateTime, is_date: bool) -> Self { + if !is_date { + return self.with_date_time(dt); + } + + debug_assert!(self.values.is_empty()); + if !self.has_parameter(&ICalendarParameterName::Value) { + self.params + .push(ICalendarParameter::value(ICalendarValueType::Date)); + } + self.values + .push(PartialDateTime::from_date_timestamp(dt.to_naive_timestamp()).into()); + self + } + + pub(super) fn with_dates(mut self, dts: Vec>, is_date: bool) -> Self { + if !is_date { + return self.with_date_times(dts); + } + + debug_assert!(self.values.is_empty()); + if !self.has_parameter(&ICalendarParameterName::Value) { + self.params + .push(ICalendarParameter::value(ICalendarValueType::Date)); + } + for dt in dts { + self.values + .push(PartialDateTime::from_date_timestamp(dt.to_naive_timestamp()).into()); + } + self + } + + pub(super) fn with_date_time(mut self, dt: DateTime) -> Self { + debug_assert!(self.values.is_empty()); + + // A date-time value cannot have the PERIOD value type + self.params.retain(|param| { + !matches!( + (¶m.name, ¶m.value), + ( + ICalendarParameterName::Value, + ICalendarParameterValue::Value(ICalendarValueType::Period) + ) + ) + }); + + // Best effort to restore the original timezone + let tz_id = self.tz_id(); + let has_tz_id = tz_id.is_some(); + self.insert_date( + dt, + has_tz_id, + !has_tz_id, + tz_id.and_then(|id| Tz::from_str(id).ok()), + ); + self + } + + pub(super) fn with_period(mut self, dt: DateTime, duration: ICalendarDuration) -> Self { + self = self.with_date_time(dt); + + if let Some(ICalendarValue::PartialDateTime(start)) = self.values.pop() { + self.values + .push(ICalendarValue::Period(ICalendarPeriod::Duration { + start: *start, + duration, + })); + } + + if !self.has_parameter(&ICalendarParameterName::Value) { + self.params + .push(ICalendarParameter::value(ICalendarValueType::Period)); + } + + self + } + + pub(super) fn with_date_times(mut self, dts: Vec>) -> Self { + debug_assert!(self.values.is_empty()); + + let tz_id = self.tz_id(); + let has_tz_id = tz_id.is_some(); + let entry_tz = tz_id + .and_then(|id| Tz::from_str(id).ok()) + .or_else(|| dts.first().map(|dt| dt.timezone())); + + for (pos, dt) in dts.into_iter().enumerate() { + self.insert_date(dt, has_tz_id, pos == 0, entry_tz); + } + + self + } + + fn insert_date( + &mut self, + mut dt: DateTime, + has_tz_id: bool, + add_tz_id: bool, + entry_tz: Option, + ) { + if let Some(tz) = entry_tz + && tz != dt.timezone() + { + dt = dt.with_timezone(&tz); + } + + let tz = dt.timezone(); + if has_tz_id { + self.values + .push(PartialDateTime::from_naive_timestamp(dt.to_naive_timestamp()).into()); + } else if tz.is_utc() { + self.values + .push(PartialDateTime::from_utc_timestamp(dt.timestamp()).into()); + } else { + if add_tz_id && let Some(tz_name) = tz.name() { + self.params + .push(ICalendarParameter::tzid(tz_name.into_owned())); + } + self.values + .push(PartialDateTime::from_naive_timestamp(dt.to_naive_timestamp()).into()); + } + } +} + +impl JSCalendarDateTime { + pub fn to_utc_date_time(&self) -> Option> { + DateTime::from_timestamp(self.timestamp, 0) + .and_then(|local| Tz::UTC.from_local_datetime(&local.naive_utc()).single()) + } + + pub fn to_naive_date_time(&self) -> Option { + DateTime::from_timestamp(self.timestamp, 0).map(|local| local.naive_utc()) + } +} + +impl From for PartialDateTime { + fn from(dt: JSCalendarDateTime) -> Self { + if !dt.is_local { + Self::from_utc_timestamp(dt.timestamp) + } else { + Self::from_naive_timestamp(dt.timestamp) + } + } +} diff --git a/vendor/calcard/src/jscalendar/export/props.rs b/vendor/calcard/src/jscalendar/export/props.rs new file mode 100644 index 00000000..8ab39466 --- /dev/null +++ b/vendor/calcard/src/jscalendar/export/props.rs @@ -0,0 +1,414 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + Parser, + common::{CalendarScale, IanaParse, IanaType, PartialDateTime, parser::Integer}, + icalendar::*, + jscalendar::{JSCalendarId, JSCalendarProperty, JSCalendarValue, export::ConvertedComponent}, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value}; + +impl ConvertedComponent<'_, I, B> { + pub(super) fn apply_conversions( + self, + mut component: ICalendarComponent, + ical: &mut ICalendar, + ) -> ICalendarComponent { + if !self.properties.is_empty() { + component.import_properties(self.properties); + } + if !self.components.is_empty() { + let mut jcal_components = self.components.into_iter(); + let mut stack = Vec::new(); + + loop { + if let Some(jcal_component) = jcal_components.next() { + let Some(items) = jcal_component.into_array() else { + continue; + }; + let mut items = items.into_iter(); + + if let ( + Some(Value::Str(name)), + Some(Value::Array(properties)), + Some(Value::Array(child_components)), + ) = (items.next(), items.next(), items.next()) + { + // Process the component + let mut sub_component = ICalendarComponent::new( + ICalendarComponentType::parse(name.as_bytes()).unwrap_or_else(|| { + ICalendarComponentType::Other(name.to_ascii_uppercase()) + }), + ); + sub_component.import_properties(properties); + if !child_components.is_empty() { + stack.push((jcal_components, component)); + jcal_components = child_components.into_iter(); + component = sub_component; + } else { + component + .component_ids + .push(ical.push_component(sub_component)); + } + } + } else if let Some((next_components, mut parent_component)) = stack.pop() { + parent_component + .component_ids + .push(ical.push_component(component)); + jcal_components = next_components; + component = parent_component; + } else { + break; + } + } + } + + component + } + + pub(super) fn retain_class_property(&mut self, keep_unknown: bool) -> bool { + let mut has_class = false; + self.properties.retain(|property| { + let Some([Value::Str(name), _, _, value, ..]) = property.as_array() else { + return true; + }; + if ICalendarProperty::parse(name.as_bytes()) != Some(ICalendarProperty::Class) { + return true; + } + let keep = keep_unknown + && !has_class + && matches!(value, Value::Str(value) + if ICalendarClassification::parse(value.as_bytes()).is_none()); + has_class |= keep; + keep + }); + has_class + } +} + +impl ICalendarComponent { + pub(super) fn import_properties( + &mut self, + props: Vec, JSCalendarValue>>, + ) { + for prop in props.into_iter().flat_map(|prop| prop.into_array()) { + let mut prop = prop.into_iter(); + let Some(name) = prop.next().and_then(|v| v.into_string()).map(|name| { + ICalendarProperty::parse(name.as_bytes()) + .unwrap_or(ICalendarProperty::Other(name.to_ascii_uppercase())) + }) else { + continue; + }; + let Some(params) = prop.next() else { + continue; + }; + let Some(value_type) = + prop.next() + .and_then(|v| v.into_string()) + .map(|v| match ICalendarValueType::parse(v.as_bytes()) { + Some(v) => IanaType::Iana(v), + None => IanaType::Other(v.to_ascii_uppercase()), + }) + else { + continue; + }; + + let (default_type, _) = name.default_types(); + let convert_type = value_type + .iana() + .filter(|&v| v != &ICalendarValueType::Unknown) + .map(|v| ValueType::Ical(*v)) + .unwrap_or(default_type); + let Some(values) = prop.next().and_then(|v| match v { + Value::Array(arr) => Some( + arr.into_iter() + .filter_map(|v| convert_value(v, &convert_type).ok()) + .collect::>(), + ), + v => convert_value(v, &convert_type).ok().map(|v| vec![v]), + }) else { + continue; + }; + + let mut entry = ICalendarEntry::new(name); + entry.import_jcal_params(params); + entry.values = values; + if convert_type != default_type { + entry.params.push(ICalendarParameter::value(value_type)); + } + self.entries.push(entry); + } + } +} + +pub(super) fn convert_value<'x, I: JSCalendarId, B: JSCalendarId>( + value: Value<'x, JSCalendarProperty, JSCalendarValue>, + value_type: &'_ ValueType, +) -> Result, JSCalendarValue>> { + match value { + Value::Element(e) => match e { + JSCalendarValue::CalendarScale(v) => Ok(ICalendarValue::CalendarScale(v)), + JSCalendarValue::DateTime(v) => Ok(ICalendarValue::PartialDateTime(Box::new(v.into()))), + JSCalendarValue::Duration(v) => Ok(ICalendarValue::Duration(v)), + JSCalendarValue::Method(v) => Ok(ICalendarValue::Method(v)), + JSCalendarValue::AlertAction(_) + | JSCalendarValue::FreeBusyStatus(_) + | JSCalendarValue::ParticipantKind(_) + | JSCalendarValue::ParticipationStatus(_) + | JSCalendarValue::Privacy(_) + | JSCalendarValue::Progress(_) + | JSCalendarValue::RelativeTo(_) + | JSCalendarValue::ScheduleAgent(_) + | JSCalendarValue::EventStatus(_) + | JSCalendarValue::Frequency(_) + | JSCalendarValue::Skip(_) + | JSCalendarValue::Weekday(_) + | JSCalendarValue::Month(_) + | JSCalendarValue::LinkRelation(_) + | JSCalendarValue::Type(_) + | JSCalendarValue::Id(_) + | JSCalendarValue::BlobId(_) + | JSCalendarValue::IdReference(_) => Err(Value::Element(e)), + }, + Value::Str(s) => { + match value_type { + ValueType::CalendarScale => { + if let Some(value) = CalendarScale::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::CalendarScale(value)); + } + } + ValueType::Method => { + if let Some(value) = ICalendarMethod::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Method(value)); + } + } + ValueType::Classification => { + if let Some(value) = ICalendarClassification::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Classification(value)); + } + } + ValueType::Status => { + if let Some(value) = ICalendarStatus::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Status(value)); + } + } + ValueType::Transparency => { + if let Some(value) = ICalendarTransparency::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Transparency(value)); + } + } + ValueType::Action => { + if let Some(value) = ICalendarAction::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Action(value)); + } + } + ValueType::BusyType => { + if let Some(value) = ICalendarFreeBusyType::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::BusyType(value)); + } + } + ValueType::ParticipantType => { + if let Some(value) = ICalendarParticipantType::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::ParticipantType(value)); + } + } + ValueType::ResourceType => { + if let Some(value) = ICalendarResourceType::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::ResourceType(value)); + } + } + ValueType::Proximity => { + if let Some(value) = ICalendarProximityValue::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Proximity(value)); + } + } + ValueType::Ical(typ) => match typ { + ICalendarValueType::Uri | ICalendarValueType::CalAddress => { + return Ok(ICalendarValue::Uri(Uri::parse(s))); + } + ICalendarValueType::Date => { + let mut dt = PartialDateTime::default(); + if dt.parse_ical_date(&mut s.as_ref().as_bytes().iter().peekable()) { + return Ok(ICalendarValue::PartialDateTime(Box::new(dt))); + } + } + ICalendarValueType::Time => { + let mut dt = PartialDateTime::default(); + if dt.parse_ical_time(&mut s.as_ref().as_bytes().iter().peekable()) { + return Ok(ICalendarValue::PartialDateTime(Box::new(dt))); + } + } + ICalendarValueType::DateTime => { + let mut dt = PartialDateTime::default(); + if dt.parse_timestamp(&mut s.as_ref().as_bytes().iter().peekable(), false) { + return Ok(ICalendarValue::PartialDateTime(Box::new(dt))); + } + } + ICalendarValueType::UtcOffset => { + let mut dt = PartialDateTime::default(); + if dt.parse_zone(&mut s.as_ref().as_bytes().iter().peekable()) { + return Ok(ICalendarValue::PartialDateTime(Box::new(dt))); + } + } + ICalendarValueType::Duration => { + if let Some(duration) = ICalendarDuration::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Duration(duration)); + } + } + ICalendarValueType::Float => { + if let Ok(float) = s.as_ref().parse::() { + return Ok(ICalendarValue::Float(float)); + } + } + ICalendarValueType::Integer => { + if let Some(integer) = Integer::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Integer(integer.0)); + } + } + ICalendarValueType::Period => { + if let Some(period) = ICalendarPeriod::parse(s.as_ref().as_bytes()) { + return Ok(ICalendarValue::Period(period)); + } + } + ICalendarValueType::Recur => { + let mut parser = Parser::new(s.as_ref()); + if let Ok(recur) = parser.rrule() { + return Ok(ICalendarValue::RecurrenceRule(Box::new(recur))); + } + } + ICalendarValueType::Boolean => { + if s.eq_ignore_ascii_case("true") { + return Ok(ICalendarValue::Boolean(true)); + } else if s.eq_ignore_ascii_case("false") { + return Ok(ICalendarValue::Boolean(false)); + } + } + ICalendarValueType::Binary => { + return Ok(match Uri::parse(s) { + Uri::Data(data) => ICalendarValue::Binary(data.data), + Uri::Location(text) => ICalendarValue::Text(text), + }); + } + ICalendarValueType::Text + | ICalendarValueType::Unknown + | ICalendarValueType::XmlReference + | ICalendarValueType::Uid => (), + }, + } + + Ok(ICalendarValue::Text(s.into_owned())) + } + Value::Bool(b) => Ok(ICalendarValue::Boolean(b)), + Value::Number(n) => match n.try_cast_to_i64() { + Ok(i) => Ok(ICalendarValue::Integer(i)), + Err(f) => Ok(ICalendarValue::Float(f)), + }, + value => Err(value), + } +} + +impl<'x, I: JSCalendarId, B: JSCalendarId> ConvertedComponent<'x, I, B> { + #[allow(clippy::type_complexity)] + pub(super) fn try_from_object( + obj: Vec<( + Key<'x, JSCalendarProperty>, + Value<'x, JSCalendarProperty, JSCalendarValue>, + )>, + ) -> Option { + let mut converted = ConvertedComponent { + name: ICalendarComponentType::Other(String::new()), + converted_props: Vec::new(), + converted_props_count: 0, + properties: Vec::new(), + components: Vec::new(), + }; + let mut has_name = false; + for (sub_property, value) in obj { + match (sub_property, value) { + (Key::Property(JSCalendarProperty::ConvertedProperties), Value::Object(obj)) => { + for (key, value) in obj.into_vec() { + let ptr = match key { + Key::Property(JSCalendarProperty::Pointer(ptr)) => ptr, + _ => JsonPointer::parse(key.to_string().as_ref()), + }; + + let mut keys = Vec::with_capacity(2); + for item in ptr.into_iter() { + match item { + JsonPointerItem::Key(key) => { + let key = match &key { + Key::Borrowed(v) if v.contains('/') => v, + Key::Owned(v) if v.contains('/') => v.as_str(), + _ => { + keys.push(key); + continue; + } + }; + for item in JsonPointer::parse(key).into_iter() { + keys.push(match item { + JsonPointerItem::Key(k) => k, + JsonPointerItem::Number(n) => Key::Owned(n.to_string()), + JsonPointerItem::Root | JsonPointerItem::Wildcard => { + continue; + } + }); + } + } + JsonPointerItem::Number(v) => { + keys.push(Key::Owned(v.to_string())); + } + JsonPointerItem::Root | JsonPointerItem::Wildcard => (), + } + } + + converted.converted_props.push((keys, value)); + } + } + (Key::Property(JSCalendarProperty::Properties), Value::Array(array)) => { + converted.properties = array; + } + (Key::Property(JSCalendarProperty::Components), Value::Array(array)) => { + converted.components = array; + } + (Key::Property(JSCalendarProperty::Name), Value::Str(text)) => { + converted.name = + ICalendarComponentType::parse(text.as_bytes()).unwrap_or_else(|| { + ICalendarComponentType::Other(text.to_ascii_uppercase()) + }); + has_name = true; + } + _ => {} + } + } + + if !converted.converted_props.is_empty() { + converted + .converted_props + .sort_unstable_by(|a, b| a.0.cmp(&b.0)); + } + + (!converted.properties.is_empty() + || !converted.components.is_empty() + || has_name + || !converted.converted_props.is_empty()) + .then_some(converted) + } + + pub(super) fn build( + entries: &mut Map<'x, JSCalendarProperty, JSCalendarValue>, + ) -> Option { + for (property, value) in entries.as_mut_vec() { + if let (Key::Property(JSCalendarProperty::ICalendar), Value::Object(obj)) = + (property, value) + { + return Self::try_from_object(std::mem::take(obj.as_mut_vec())); + } + } + + None + } +} diff --git a/vendor/calcard/src/jscalendar/import/convert.rs b/vendor/calcard/src/jscalendar/import/convert.rs new file mode 100644 index 00000000..72137e7b --- /dev/null +++ b/vendor/calcard/src/jscalendar/import/convert.rs @@ -0,0 +1,2049 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::timezone::{Tz, TzTimestamp}, + icalendar::{timezone::TzResolver, *}, + jscalendar::{ + import::{ConversionOptions, EntryState, State, params::ExtractParams}, + *, + }, +}; +use chrono::DateTime; +use jmap_tools::{JsonPointer, Key, Map, Value}; + +impl ICalendar { + pub fn into_jscalendar( + mut self, + ) -> JSCalendar<'static, I, B> { + JSCalendar( + self.to_jscalendar( + &self.build_owned_tz_resolver(), + 0, + &ConversionOptions::default(), + ) + .into_object(), + ) + } + + pub fn into_jscalendar_with_opt( + mut self, + options: ConversionOptions, + ) -> JSCalendar<'static, I, B> { + JSCalendar( + self.to_jscalendar(&self.build_owned_tz_resolver(), 0, &options) + .into_object(), + ) + } +} + +impl ICalendar { + #[allow(clippy::wrong_self_convention)] + pub(super) fn to_jscalendar( + &mut self, + tz_resolver: &TzResolver, + component_id: u32, + options: &ConversionOptions, + ) -> State { + let mut state = State { + include_ical_components: options.include_ical_components, + ..Default::default() + }; + + // Take component + let Some(component) = self.components.get_mut(component_id as usize) else { + debug_assert!(false, "Invalid component ID {component_id}"); + return state; + }; + let mut entries = std::mem::take(&mut component.entries); + state.component_type = component.component_type.clone(); + + // Process subcomponents + let mut group_components = Vec::new(); + let mut alarm_component_ids = Vec::new(); + let mut alarm_with_related = false; + let mut unsupported_component_ids = Vec::new(); + let mut uid_jsid_mappings = Vec::new(); + let mut has_locations = false; + + for component_id in std::mem::take(&mut component.component_ids) { + let component = &mut self.components[component_id as usize]; + match (&state.component_type, &component.component_type) { + ( + ICalendarComponentType::VCalendar, + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + group_components.push(self.to_jscalendar(tz_resolver, component_id, options)); + } + ( + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + component_type @ (ICalendarComponentType::Participant + | ICalendarComponentType::VLocation), + ) => { + let entries = state.get_mut_object_or_insert(match component_type { + ICalendarComponentType::Participant => JSCalendarProperty::Participants, + ICalendarComponentType::VLocation => { + has_locations = true; + JSCalendarProperty::Locations + } + _ => unreachable!(), + }); + let mut subcomponent_state = + self.to_jscalendar(tz_resolver, component_id, options); + let jsid = subcomponent_state.jsid.take(); + let subcomponent = subcomponent_state.into_object(); + entries.insert_named(jsid, subcomponent); + } + ( + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ICalendarComponentType::VAlarm, + ) => { + let mut jsid = None; + + for entry in &mut component.entries { + match &entry.name { + ICalendarProperty::RelatedTo => { + alarm_with_related = true; + } + ICalendarProperty::Jsid => { + jsid = std::mem::take(&mut entry.values) + .pop() + .and_then(|v| v.into_text()); + } + _ => {} + } + } + let jsid = jsid.unwrap_or_else(|| { + Cow::Owned(format!("k{}", alarm_component_ids.len() + 1)) + }); + if let Some(uid) = component.uid() { + uid_jsid_mappings.push((uid.to_string(), jsid.clone())); + } + alarm_component_ids.push((component_id, jsid)); + } + _ => { + unsupported_component_ids.push(component_id); + } + } + } + + // Process alarms + if !alarm_component_ids.is_empty() { + for (component_id, jsid) in alarm_component_ids { + if alarm_with_related { + for entry in &mut self.components[component_id as usize].entries { + if matches!(entry.name, ICalendarProperty::RelatedTo) { + for value in &mut entry.values { + if let Some((_, jsid)) = value.as_text().and_then(|uid| { + uid_jsid_mappings.iter().find(|(u, _)| u == uid) + }) { + *value = ICalendarValue::Text(jsid.to_string()); + } + } + } + } + } + + state + .get_mut_object_or_insert(JSCalendarProperty::Alerts) + .insert_unchecked( + Key::from(jsid), + self.to_jscalendar(tz_resolver, component_id, options) + .into_object(), + ); + } + } + + // Build group component list + let mut group_objects = Vec::with_capacity(group_components.len()); + if !group_components.is_empty() { + // Order by UID placing the main components first + group_components.sort_unstable_by(|a, b| match a.uid.cmp(&b.uid) { + std::cmp::Ordering::Equal => { + a.recurrence_id.is_some().cmp(&b.recurrence_id.is_some()) + } + other => other, + }); + let mut group_components = group_components.into_iter().peekable(); + + // Bundle recurrence overrides together by UID + while let Some(mut component) = group_components.next() { + let mut instances = Vec::new(); + + if component.uid.is_some() && component.recurrence_id.is_none() { + let mut privacy = component.privacy(); + + while let Some(mut recurrence) = group_components + .next_if(|r| r.uid == component.uid && r.recurrence_id.is_some()) + { + let Some(recurrence_id) = recurrence.recurrence_id.take() else { + continue; + }; + let recurrence_id = recurrence_id + .with_timezone(&component.tz_start.unwrap_or_default()) + .to_naive_timestamp(); + let _ = recurrence.uid.take(); + + privacy = privacy.max(recurrence.privacy()); + recurrence.set_is_recurrence_instance(); + recurrence.remove_forbidden_override_patches(); + + component + .get_mut_object_or_insert(JSCalendarProperty::RecurrenceOverrides) + .insert( + Key::Property(JSCalendarProperty::DateTime( + JSCalendarDateTime::new(recurrence_id, true), + )), + recurrence.into_object(), + ); + } + + if let Some(privacy) = privacy { + component.raise_privacy(privacy); + } + } else if component.uid.is_some() { + while let Some(instance) = + group_components.next_if(|next| next.uid == component.uid) + { + instances.push(instance); + } + + if let Some(privacy) = instances + .iter() + .filter_map(State::privacy) + .max() + .max(component.privacy()) + { + component.raise_privacy(privacy); + for instance in &mut instances { + instance.raise_privacy(privacy); + } + } + } + + if options.return_first { + return component; + } + group_objects.push(component.into_object()); + group_objects.extend(instances.into_iter().map(State::into_object)); + } + } + + // Build unsupported component jcal + if options.include_ical_components && !unsupported_component_ids.is_empty() { + let mut component_iter = unsupported_component_ids.into_iter(); + let mut component_stack = Vec::with_capacity(4); + let mut components = vec![]; + + loop { + if let Some(component_id) = component_iter.next() { + let component = self.components.get_mut(component_id as usize).unwrap(); + + let mut entries = Vec::with_capacity(component.entries.len()); + for entry in std::mem::take(&mut component.entries) { + entries.push(EntryState::new(entry).into_jcal()); + } + + components.push(Value::Array(vec![ + Value::Str( + std::mem::take(&mut component.component_type) + .into_string() + .to_ascii_lowercase() + .into(), + ), + Value::Array(entries), + Value::Array(vec![]), + ])); + + if !component.component_ids.is_empty() { + component_stack.push((components, component_iter)); + component_iter = std::mem::take(&mut component.component_ids).into_iter(); + components = vec![]; + } + } else if let Some((mut parent_components, iter)) = component_stack.pop() { + if let Some(parent_component) = parent_components + .last_mut() + .and_then(|v| v.as_array_mut()) + .and_then(|v| v.last_mut()) + .and_then(|v| v.as_array_mut()) + { + if !parent_component.is_empty() { + parent_component.extend(components); + } else { + *parent_component = components; + } + } else { + debug_assert!(false, "Invalid component stack state"); + } + components = parent_components; + component_iter = iter; + } else { + break; + } + } + + state.ical_components = Some(Value::Array(components)); + } + + let mut main_location_id = None; + + entries.sort_by_key(|entry| match &entry.name { + ICalendarProperty::Dtstart | ICalendarProperty::Jsid => 0, + ICalendarProperty::RecurrenceId => 1, + ICalendarProperty::Dtend + | ICalendarProperty::Due + | ICalendarProperty::Location + | ICalendarProperty::Name + | ICalendarProperty::Attendee => 2, + _ => 3, + }); + let mut start_date = None; + let mut has_owner = false; + + for entry in entries { + let mut entry = EntryState::new(entry); + let mut values = std::mem::take(&mut entry.entry.values).into_iter(); + + match ( + &entry.entry.name, + values.next().map(|v| v.uri_to_string()), + &state.component_type, + ) { + ( + ICalendarProperty::Acknowledged, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VAlarm, + ) if value.has_date_and_time() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Acknowledged), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + value.to_timestamp().unwrap(), + false, + ))), + ); + entry.set_converted_to::(&[JSCalendarProperty::Acknowledged:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Action, + Some(ICalendarValue::Action( + value @ (ICalendarAction::Display | ICalendarAction::Email), + )), + ICalendarComponentType::VAlarm, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Action), + Value::Element(JSCalendarValue::AlertAction(match value { + ICalendarAction::Display => JSCalendarAlertAction::Display, + ICalendarAction::Email => JSCalendarAlertAction::Email, + _ => unreachable!(), + })), + ); + entry.set_converted_to::(&[JSCalendarProperty::Action:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Attach, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::Participant + | ICalendarComponentType::VLocation + | ICalendarComponentType::VCalendar, + ) => { + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Fmttype, + ICalendarParameterName::Filename, + ICalendarParameterName::Size, + ICalendarParameterName::Jsid, + ], + JSCalendarProperty::Links, + [ + ( + Key::Property(JSCalendarProperty::Href), + Value::Str(uri.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Link)), + ), + ( + Key::Property(JSCalendarProperty::Rel), + Value::Element(JSCalendarValue::LinkRelation( + LinkRelation::Enclosure, + )), + ), + ], + ); + entry.set_map_name(); + } + ( + ICalendarProperty::Image, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::Participant + | ICalendarComponentType::VLocation + | ICalendarComponentType::VCalendar, + ) => { + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Display, + ICalendarParameterName::Fmttype, + ICalendarParameterName::Filename, + ICalendarParameterName::Size, + ICalendarParameterName::Jsid, + ], + JSCalendarProperty::Links, + [ + ( + Key::Property(JSCalendarProperty::Href), + Value::Str(uri.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Link)), + ), + ( + Key::Property(JSCalendarProperty::Rel), + Value::Element(JSCalendarValue::LinkRelation(LinkRelation::Icon)), + ), + ], + ); + entry.set_map_name(); + } + ( + ICalendarProperty::Link, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::Participant + | ICalendarComponentType::VLocation + | ICalendarComponentType::VCalendar, + ) => { + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Fmttype, + ICalendarParameterName::Label, + ICalendarParameterName::Linkrel, + ICalendarParameterName::Jsid, + ], + JSCalendarProperty::Links, + [ + ( + Key::Property(JSCalendarProperty::Href), + Value::Str(uri.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Link)), + ), + ], + ); + entry.set_map_name(); + } + ( + ICalendarProperty::Url, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::Participant + | ICalendarComponentType::VLocation + | ICalendarComponentType::VCalendar, + ) => { + state.map_named_entry( + &mut entry, + &[ICalendarParameterName::Label, ICalendarParameterName::Jsid], + JSCalendarProperty::Links, + [ + ( + Key::Property(JSCalendarProperty::Href), + Value::Str(uri.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Link)), + ), + ( + Key::Property(JSCalendarProperty::Rel), + Value::Element(JSCalendarValue::LinkRelation( + LinkRelation::Describedby, + )), + ), + ], + ); + entry.set_map_name(); + } + ( + ICalendarProperty::Attendee, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let is_task = matches!(&state.component_type, ICalendarComponentType::VTodo); + let mut progress = None; + + for param in &mut entry.entry.params { + match (¶m.name, &mut param.value) { + ( + ICalendarParameterName::Partstat, + ICalendarParameterValue::Partstat(partstat), + ) if is_task => { + let status = match partstat { + ICalendarParticipationStatus::Completed => { + JSCalendarProgress::Completed + } + ICalendarParticipationStatus::InProcess => { + JSCalendarProgress::InProcess + } + ICalendarParticipationStatus::Failed => { + JSCalendarProgress::Failed + } + _ => { + continue; + } + }; + *partstat = ICalendarParticipationStatus::Accepted; + progress = Some(( + Key::Property(JSCalendarProperty::Progress), + Value::Element(JSCalendarValue::Progress(status)), + )); + } + (ICalendarParameterName::Role, ICalendarParameterValue::Role(role)) => { + has_owner |= matches!(role, ICalendarParticipationRole::Owner); + } + _ => {} + } + } + + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Cn, + ICalendarParameterName::Cutype, + ICalendarParameterName::DelegatedFrom, + ICalendarParameterName::DelegatedTo, + ICalendarParameterName::Email, + ICalendarParameterName::Member, + ICalendarParameterName::Partstat, + ICalendarParameterName::Role, + ICalendarParameterName::Rsvp, + ICalendarParameterName::SentBy, + ICalendarParameterName::Jsid, + ], + JSCalendarProperty::Participants, + [ + Some(( + Key::Property(JSCalendarProperty::CalendarAddress), + Value::Str(uri.to_string().into()), + )), + Some(( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Participant)), + )), + progress, + ] + .into_iter() + .flatten(), + ); + } + ( + ICalendarProperty::Organizer, + Some(ICalendarValue::Text(uri)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::OrganizerCalendarAddress), + Value::Str(uri.clone().into()), + ); + + if let Some(sent_by) = entry + .entry + .params + .iter() + .find(|param| param.name == ICalendarParameterName::SentBy) + .and_then(|param| param.value.as_text()) + { + state.entries.insert( + Key::Property(JSCalendarProperty::SentBy), + Value::Str(sent_by.to_string().into()), + ); + } + + /* + It also converts to a Participant object in the "participants" property, + if any of its parameters convert to a Participant object property, or + if none of the ATTENDEE properties in the iCalendar component have + the ROLE parameter set to "OWNER" + + An ORGANIZER property, an ATTENDEE property, and a PARTICIPANT component + in the same iCalendar component all convert to the same Participant + object, if their converted "calendarAddress" property values are equal. + */ + + if !has_owner + || entry.entry.params.iter().any(|p| { + matches!( + p.name, + ICalendarParameterName::Cn + | ICalendarParameterName::Email + | ICalendarParameterName::SentBy + | ICalendarParameterName::Jsid + ) + }) + { + let participant_id = state.find_participant_by_address(uri.as_ref()); + + state.map_named_entry_with_id( + &mut entry, + &[ + ICalendarParameterName::Cn, + ICalendarParameterName::Email, + ICalendarParameterName::SentBy, + ICalendarParameterName::Jsid, + ], + JSCalendarProperty::Participants, + [ + ( + Key::Property(JSCalendarProperty::CalendarAddress), + Value::Str(uri.to_string().into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type( + JSCalendarType::Participant, + )), + ), + ( + Key::Property(JSCalendarProperty::Roles), + Value::Object(Map::from(vec![( + Key::Property(JSCalendarProperty::ParticipantRole( + JSCalendarParticipantRole::Owner, + )), + Value::Bool(true), + )])), + ), + ], + participant_id, + ); + } else { + continue; + } + } + ( + ICalendarProperty::CalendarAddress, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::Participant, + ) => { + if state.jsid.is_none() { + state.jsid = Some(uuid5(&value)); + } + state.entries.insert( + Key::Property(JSCalendarProperty::CalendarAddress), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::CalendarAddress:: + .to_string() + .as_ref()]); + entry.set_map_name(); + } + ( + ICalendarProperty::Categories, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + let obj = state + .entries + .entry(Key::Property(JSCalendarProperty::Keywords)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap(); + obj.insert(Key::Owned(value), Value::Bool(true)); + for value in values { + if let Some(value) = value.into_text() { + obj.insert(Key::from(value), Value::Bool(true)); + } + } + entry.set_converted_to::(&[JSCalendarProperty::Keywords:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Class, + Some(ICalendarValue::Classification(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.merge_privacy(match value { + ICalendarClassification::Public => JSCalendarPrivacy::Public, + ICalendarClassification::Private => JSCalendarPrivacy::Private, + ICalendarClassification::Confidential => JSCalendarPrivacy::Secret, + }); + entry.set_converted_to::(&[JSCalendarProperty::Privacy:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Class, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.merge_privacy(JSCalendarPrivacy::Private); + entry.entry.values = [ICalendarValue::Text(value)] + .into_iter() + .chain(values) + .collect(); + } + ( + ICalendarProperty::Color, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Color), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Color:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Concept, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) => { + let obj = state + .entries + .entry(Key::Property(JSCalendarProperty::Categories)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap(); + obj.insert(Key::Owned(value), Value::Bool(true)); + for value in values { + if let Some(value) = value.into_text() { + obj.insert(Key::from(value), Value::Bool(true)); + } + } + entry.set_converted_to::(&[JSCalendarProperty::Categories:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Conference, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Jsid, + ICalendarParameterName::Feature, + ICalendarParameterName::Label, + ], + JSCalendarProperty::VirtualLocations, + [ + ( + Key::Property(JSCalendarProperty::Uri), + Value::Str(value.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type( + JSCalendarType::VirtualLocation, + )), + ), + ], + ); + } + ( + ICalendarProperty::Coordinates, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VLocation, + ) => { + if state.jsid.is_none() { + state.jsid = Some(uuid5(&value)); + } + state.entries.insert( + Key::Property(JSCalendarProperty::Coordinates), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Coordinates:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Geo, + Some(ICalendarValue::Float(coord1)), + ICalendarComponentType::VLocation, + ) if !entry.entry.is_derived() => { + let coord2 = values.next().and_then(|v| v.as_float()).unwrap_or_default(); + + state.entries.insert( + Key::Property(JSCalendarProperty::Coordinates), + Value::Str(format!("geo:{coord1},{coord2}").into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Coordinates:: + .to_string() + .as_ref()]); + entry.set_map_name(); + } + ( + ICalendarProperty::Geo, + Some(ICalendarValue::Float(coord1)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if !entry.entry.is_derived() => { + let coord2 = values.next().and_then(|v| v.as_float()).unwrap_or_default(); + if entry.entry.jsid().is_none() + && let Some(main_location_id) = main_location_id.take() + { + entry + .entry + .add_param(ICalendarParameter::jsid(main_location_id)); + } + + state.map_named_entry( + &mut entry, + &[ICalendarParameterName::Jsid], + JSCalendarProperty::Locations, + [ + ( + Key::Property(JSCalendarProperty::Coordinates), + Value::Str(format!("geo:{coord1},{coord2}").into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Location)), + ), + ], + ); + entry.set_map_name(); + } + ( + ICalendarProperty::Name, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VLocation, + ) => { + if state.jsid.is_none() { + state.jsid = Some(uuid5(&value)); + } + state.entries.insert( + Key::Property(JSCalendarProperty::Name), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Name:: + .to_string() + .as_ref()]); + state.set_map_component(); + } + ( + ICalendarProperty::Name, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VCalendar, + ) + | ( + ICalendarProperty::Summary, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state + .entries + .extract_params(&mut entry.entry, &[ICalendarParameterName::Language]); + state.entries.insert( + Key::Property(JSCalendarProperty::Title), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Title:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Summary, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::Participant, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Name), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Name:: + .to_string() + .as_ref()]); + state.set_map_component(); + } + ( + ICalendarProperty::Location, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let location_id = if let Some(location_id) = entry.entry.jsid() { + main_location_id = Some(location_id.to_string()); + location_id + } else { + main_location_id = Some(uuid5(&value)); + main_location_id.as_deref().unwrap() + }; + + if has_locations { + state.entries.insert( + Key::Property(JSCalendarProperty::MainLocationId), + Value::Str(location_id.to_string().into()), + ); + } + + state.map_named_entry( + &mut entry, + &[ + ICalendarParameterName::Jsid, + ICalendarParameterName::Derived, + ], + JSCalendarProperty::Locations, + [ + ( + Key::Property(JSCalendarProperty::Name), + Value::Str(value.into()), + ), + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Location)), + ), + ], + ); + } + ( + ICalendarProperty::LocationType, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VLocation, + ) => { + let obj = state + .entries + .entry(Key::Property(JSCalendarProperty::LocationTypes)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap(); + obj.insert(Key::Owned(value), Value::Bool(true)); + for value in values { + if let Some(value) = value.into_text() { + obj.insert(Key::from(value), Value::Bool(true)); + } + } + entry.set_converted_to::(&[JSCalendarProperty::LocationTypes:: + .to_string() + .as_ref()]); + } + + ( + ICalendarProperty::LastModified, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VCalendar, + ) if value.has_date_and_time() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Updated), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + value.to_timestamp().unwrap(), + false, + ))), + ); + entry.set_converted_to::(&[JSCalendarProperty::Updated:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Created, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VCalendar, + ) if value.has_date_and_time() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Created), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + value.to_timestamp().unwrap(), + false, + ))), + ); + entry.set_converted_to::(&[JSCalendarProperty::Created:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Description, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::Participant + | ICalendarComponentType::VCalendar + | ICalendarComponentType::VLocation, + ) if !entry.entry.is_derived() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Description), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Description:: + .to_string() + .as_ref()]); + if matches!(state.component_type, ICalendarComponentType::Participant) { + state.set_map_component(); + } + } + ( + ICalendarProperty::StyledDescription, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if !entry.entry.is_derived() + && entry + .entry + .parameter(&ICalendarParameterName::Fmttype) + .is_none_or(|v| v.as_text().is_some_and(|v| v.starts_with("text/"))) => + { + state + .entries + .extract_params(&mut entry.entry, &[ICalendarParameterName::Fmttype]); + state.entries.insert( + Key::Property(JSCalendarProperty::Description), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Description:: + .to_string() + .as_ref()]); + if !state + .entries + .contains_key(&Key::Property(JSCalendarProperty::DescriptionContentType)) + { + entry.set_map_name(); + } + } + ( + ICalendarProperty::Dtstart, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if value.has_date() => { + let tzid = entry.entry.tz_id(); + state.tz_start = tzid.and_then(|v| tz_resolver.resolve(v)); + if let Some(dt) = value + .to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(state.tz_start.unwrap_or_default())) + { + state.has_dates = true; + state.entries.insert( + Key::Property(JSCalendarProperty::Start), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + dt.to_naive_timestamp(), + true, + ))), + ); + + // Remove IANA TZ references + if tzid.is_some() + && state.tz_start.and_then(|tz| tz.name()).as_deref() == tzid + { + entry + .entry + .params + .retain(|p| p.name != ICalendarParameterName::Tzid); + } + + if !value.has_time() { + state.entries.insert( + Key::Property(JSCalendarProperty::ShowWithoutTime), + Value::Bool(true), + ); + } + entry.set_converted_to::(&[JSCalendarProperty::Start:: + .to_string() + .as_ref()]); + start_date = Some(dt); + + if state.tz_start.is_none() { + state.tz_start = dt.timezone().to_resolved(); + } + } else { + state.tz_start = None; + entry.entry.values = [ICalendarValue::PartialDateTime(value)] + .into_iter() + .chain(values) + .collect(); + } + } + ( + ICalendarProperty::Dtend, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent, + ) if value.has_date() && start_date.is_some() => { + let tzid = entry.entry.tz_id(); + state.tz_end = tzid.and_then(|v| tz_resolver.resolve(v)).or(state.tz_start); + if let Some((delta, dt)) = value + .to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(state.tz_end.unwrap_or_default())) + .and_then(|dt| { + let delta = dt.signed_duration_since(start_date.unwrap()).num_seconds(); + (delta > 0).then_some((delta, dt)) + }) + { + state.has_dates = true; + + /* + If the value type of the DTEND property is DATE, then the duration is + the number of days or weeks between the DTSTART and DTEND property + values. If the value type is DATE-TIME, then the duration is the + timespan between the DTSTART and DTEND property values when converted + to UTC time. + */ + + let days = if !value.has_time() { + dt.date_naive() + .signed_duration_since(start_date.unwrap().date_naive()) + .num_days() + } else { + 0 + }; + let duration = if days > 0 { + ICalendarDuration::from_days(days) + } else { + ICalendarDuration::from_seconds(delta) + }; + + state.entries.insert( + Key::Property(JSCalendarProperty::Duration), + Value::Element(JSCalendarValue::Duration(duration)), + ); + + // Remove IANA TZ references + if tzid.is_some() + && state.tz_end.and_then(|tz| tz.name()).as_deref() == tzid + { + entry + .entry + .params + .retain(|p| p.name != ICalendarParameterName::Tzid); + } + + if !value.has_time() { + state.entries.insert( + Key::Property(JSCalendarProperty::ShowWithoutTime), + Value::Bool(true), + ); + } + entry.set_converted_to::(&[JSCalendarProperty::Duration:: + .to_string() + .as_ref()]); + entry.set_map_name(); + + if state.tz_end.is_none() { + state.tz_end = dt.timezone().to_resolved(); + } + if state.tz_start.is_none() { + state.tz_start = state.tz_end; + } + } else { + state.tz_end = None; + entry.entry.values = [ICalendarValue::PartialDateTime(value)] + .into_iter() + .chain(values) + .collect(); + } + } + ( + ICalendarProperty::Due, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VTodo, + ) if value.has_date() => { + let tzid = entry.entry.tz_id(); + let due_tz = tzid.and_then(|v| tz_resolver.resolve(v)).or(state.tz_start); + if let Some(dt) = value + .to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(due_tz.unwrap_or_default())) + { + // Remove IANA TZ references + if tzid.is_some() + && due_tz != state.tz_start + && due_tz.and_then(|tz| tz.name()).as_deref() == tzid + { + entry + .entry + .params + .retain(|p| p.name != ICalendarParameterName::Tzid); + } + + state.has_dates = true; + if state.tz_start.is_none() { + state.tz_start = dt.timezone().to_resolved(); + } + + state.entries.insert( + Key::Property(JSCalendarProperty::Due), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + dt.with_timezone(&state.tz_start.unwrap_or_default()) + .to_naive_timestamp(), + true, + ))), + ); + + if !value.has_time() { + state.entries.insert( + Key::Property(JSCalendarProperty::ShowWithoutTime), + Value::Bool(true), + ); + } + + entry.set_converted_to::(&[JSCalendarProperty::Due:: + .to_string() + .as_ref()]); + } else { + entry.entry.values = [ICalendarValue::PartialDateTime(value)] + .into_iter() + .chain(values) + .collect(); + } + } + ( + ICalendarProperty::Dtstamp, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if value.has_date_and_time() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Updated), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + value.to_timestamp().unwrap_or_default(), + false, + ))), + ); + + entry.set_converted_to::(&[JSCalendarProperty::Updated:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Duration, + Some(ICalendarValue::Duration(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Duration), + Value::Element(JSCalendarValue::Duration(value)), + ); + + entry.set_converted_to::(&[JSCalendarProperty::Duration:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::EstimatedDuration, + Some(ICalendarValue::Duration(value)), + ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::EstimatedDuration), + Value::Element(JSCalendarValue::Duration(value)), + ); + + entry.set_converted_to::(&[JSCalendarProperty::EstimatedDuration:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::RecurrenceId, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if value.has_date_and_time() => { + let tzid = entry.entry.tz_id(); + let rid_tz = tzid.and_then(|v| tz_resolver.resolve(v)).or(state.tz_start); + + if let Some(dt) = value + .to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(rid_tz.unwrap_or_default())) + { + state.has_dates = true; + state.recurrence_id = Some(dt); + + // Remove IANA TZ references + if tzid.is_some() && rid_tz.and_then(|tz| tz.name()).as_deref() == tzid { + entry + .entry + .params + .retain(|p| p.name != ICalendarParameterName::Tzid); + } + + entry.set_converted_to::(&[JSCalendarProperty::RecurrenceId:: + .to_string() + .as_ref()]); + + if state.tz_start.is_none() { + state.tz_start = dt.timezone().to_resolved(); + } + } else { + entry.entry.values = [ICalendarValue::PartialDateTime(value)] + .into_iter() + .chain(values) + .collect(); + } + } + ( + ICalendarProperty::Rdate, + Some(value @ ICalendarValue::Period(_)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + /* + The property value converts to the key in the "recurrenceOverrides" + property value, for values of type PERIOD this only applies to the + period start. The duration of a PERIOD converts to the PatchObject's + "duration" property. + */ + + let tz = entry + .entry + .tz_id() + .and_then(|v| tz_resolver.resolve(v)) + .or(state.tz_start) + .unwrap_or_default(); + + // Restore the values, they are preserved as-is unless every period converts + entry.entry.values = [value].into_iter().chain(values).collect(); + + if let Some((start, _)) = entry + .entry + .values + .first() + .and_then(|value| period_to_date_time(value, tz)) + && entry + .entry + .values + .iter() + .skip(1) + .all(|value| period_to_date_time(value, tz).is_some()) + { + state.has_dates = true; + if state.tz_start.is_none() { + state.tz_start = start.timezone().to_resolved(); + } + + let tz_start = state.tz_start.unwrap_or_default(); + let overrides_name = + JSCalendarProperty::RecurrenceOverrides::.to_string(); + + // Each period converts to its own recurrence override + for pos in 0..entry.entry.values.len() { + let (dt, duration) = + period_to_date_time(&entry.entry.values[pos], tz).unwrap(); + let key = Key::Property(JSCalendarProperty::DateTime( + JSCalendarDateTime::new( + dt.with_timezone(&tz_start).to_naive_timestamp(), + true, + ), + )); + let key_name = key.to_string().into_owned(); + + state.insert_recurrence_override( + key, + Value::Object(Map::from(vec![( + Key::Property(JSCalendarProperty::Duration), + Value::Element(JSCalendarValue::Duration(duration)), + )])), + ); + + if pos == 0 { + entry.set_converted_to::(&[ + overrides_name.as_ref(), + key_name.as_str(), + ]); + entry.set_map_name(); + } else { + state.add_period_conversion_prop(format!( + "{}/{}", + overrides_name.as_ref(), + key_name + )); + } + } + } + } + ( + ICalendarProperty::Rdate | ICalendarProperty::Exdate, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) if value.has_date() => { + let tzid = entry.entry.tz_id(); + let tz = tzid.and_then(|v| tz_resolver.resolve(v)).or(state.tz_start); + + if let Some(dt) = value + .to_date_time() + .and_then(|dt| dt.to_date_time_with_tz(tz.unwrap_or_default())) + { + state.has_dates = true; + + // Remove IANA TZ references + if tzid.is_some() + && tz == state.tz_start + && tz.and_then(|tz| tz.name()).as_deref() == tzid + { + entry + .entry + .params + .retain(|p| p.name != ICalendarParameterName::Tzid); + } + + if state.tz_start.is_none() { + state.tz_start = dt.timezone().to_resolved(); + } + + let overrides = state + .entries + .entry(Key::Property(JSCalendarProperty::RecurrenceOverrides)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap(); + let value = Value::Object(Map::from( + if matches!(entry.entry.name, ICalendarProperty::Exdate) { + vec![( + Key::Property(JSCalendarProperty::Excluded), + Value::Bool(true), + )] + } else { + vec![] + }, + )); + + for (pos, dt) in [dt] + .into_iter() + .chain(values.filter_map(|v| { + v.into_partial_date_time() + .and_then(|dt| dt.to_date_time()) + .and_then(|dt| dt.to_date_time_with_tz(tz.unwrap_or_default())) + })) + .enumerate() + { + let key = Key::Property(JSCalendarProperty::DateTime( + JSCalendarDateTime::new( + dt.with_timezone(&state.tz_start.unwrap_or_default()) + .to_naive_timestamp(), + true, + ), + )); + + if pos == 0 { + entry.set_converted_to::(&[ + JSCalendarProperty::RecurrenceOverrides:: + .to_string() + .as_ref(), + key.to_string().as_ref().to_string().as_ref(), + ]); + } + + overrides.insert(key, value.clone()); + } + } else { + entry.entry.values = [ICalendarValue::PartialDateTime(value)] + .into_iter() + .chain(values) + .collect(); + } + } + ( + ICalendarProperty::Rrule, + Some(ICalendarValue::RecurrenceRule(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + let mut rrule = Map::from(Vec::with_capacity(4)); + + rrule.insert_unchecked( + JSCalendarProperty::Frequency, + Value::Element(JSCalendarValue::Frequency(value.freq)), + ); + + for (key, value) in [ + ( + JSCalendarProperty::Until, + value + .until + .as_ref() + .and_then(|v| { + v.to_date_time_with_tz(state.tz_start.unwrap_or_default()) + }) + .map(|dt| { + Value::Element(JSCalendarValue::DateTime( + JSCalendarDateTime::new( + dt.with_timezone(&state.tz_start.unwrap_or_default()) + .to_naive_timestamp(), + true, + ), + )) + }), + ), + ( + JSCalendarProperty::Count, + value.count.map(|v| Value::Number((v as u64).into())), + ), + ( + JSCalendarProperty::Interval, + value.interval.map(|v| Value::Number((v as u64).into())), + ), + ( + JSCalendarProperty::FirstDayOfWeek, + value + .wkst + .map(|v| Value::Element(JSCalendarValue::Weekday(v))), + ), + ( + JSCalendarProperty::Rscale, + value + .rscale + .map(|v| Value::Element(JSCalendarValue::CalendarScale(v))), + ), + ( + JSCalendarProperty::Skip, + value.skip.map(|v| Value::Element(JSCalendarValue::Skip(v))), + ), + ] { + if let Some(value) = value { + rrule.insert_unchecked(key, value); + } + } + + for (key, values) in [ + ( + JSCalendarProperty::BySecond, + (!value.bysecond.is_empty()).then(|| { + value + .bysecond + .iter() + .map(|&v| Value::Number((v as u64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByMinute, + (!value.byminute.is_empty()).then(|| { + value + .byminute + .iter() + .map(|&v| Value::Number((v as u64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByHour, + (!value.byhour.is_empty()).then(|| { + value + .byhour + .iter() + .map(|&v| Value::Number((v as u64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByDay, + (!value.byday.is_empty()).then(|| { + value + .byday + .iter() + .map(|v| { + Value::Object(Map::from_iter( + [ + Some(( + Key::Property(JSCalendarProperty::Day), + Value::Element(JSCalendarValue::Weekday( + v.weekday, + )), + )), + v.ordwk.map(|v| { + ( + Key::Property( + JSCalendarProperty::NthOfPeriod, + ), + Value::Number((v as i64).into()), + ) + }), + ] + .into_iter() + .flatten(), + )) + }) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByMonthDay, + (!value.bymonthday.is_empty()).then(|| { + value + .bymonthday + .iter() + .map(|&v| Value::Number((v as i64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByYearDay, + (!value.byyearday.is_empty()).then(|| { + value + .byyearday + .iter() + .map(|&v| Value::Number((v as i64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByWeekNo, + (!value.byweekno.is_empty()).then(|| { + value + .byweekno + .iter() + .map(|&v| Value::Number((v as i64).into())) + .collect::>() + }), + ), + ( + JSCalendarProperty::ByMonth, + (!value.bymonth.is_empty()).then(|| { + value + .bymonth + .iter() + .map(|&v| Value::Element(JSCalendarValue::Month(v))) + .collect::>() + }), + ), + ( + JSCalendarProperty::BySetPosition, + (!value.bysetpos.is_empty()).then(|| { + value + .bysetpos + .iter() + .map(|&v| Value::Number((v as i64).into())) + .collect::>() + }), + ), + ] { + if let Some(values) = values { + rrule.insert_unchecked(key, Value::Array(values)); + } + } + + state.entries.insert( + Key::Property(JSCalendarProperty::RecurrenceRule), + Value::Object(rrule), + ); + + entry.set_converted_to::(&[JSCalendarProperty::RecurrenceRule:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Method, + Some(ICalendarValue::Method(value)), + ICalendarComponentType::VCalendar, + ) => { + for object in &mut group_objects { + object.as_object_mut().unwrap().insert_unchecked( + Key::Property(JSCalendarProperty::Method), + Value::Element(JSCalendarValue::Method(value.clone())), + ); + } + continue; + } + ( + ICalendarProperty::PercentComplete, + Some(ICalendarValue::Integer(value)), + ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::PercentComplete), + Value::Number(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::PercentComplete:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Priority, + Some(ICalendarValue::Integer(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Priority), + Value::Number(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Priority:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Sequence, + Some(ICalendarValue::Integer(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Sequence), + Value::Number(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Sequence:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Prodid, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VCalendar, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::ProdId), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::ProdId:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::RelatedTo, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo + | ICalendarComponentType::VAlarm, + ) => { + let mut rels: Map<'_, JSCalendarProperty, JSCalendarValue> = + Map::from(vec![]); + for param in std::mem::take(&mut entry.entry.params) { + match (param.name, param.value) { + ( + ICalendarParameterName::Reltype, + ICalendarParameterValue::Reltype(value), + ) => { + rels.insert( + match value { + ICalendarRelationshipType::Child => { + Key::Property(JSCalendarProperty::RelationValue( + JSCalendarRelation::Child, + )) + } + ICalendarRelationshipType::Parent => { + Key::Property(JSCalendarProperty::RelationValue( + JSCalendarRelation::Parent, + )) + } + ICalendarRelationshipType::Snooze => { + Key::Property(JSCalendarProperty::RelationValue( + JSCalendarRelation::Snooze, + )) + } + ICalendarRelationshipType::First => { + Key::Property(JSCalendarProperty::RelationValue( + JSCalendarRelation::First, + )) + } + ICalendarRelationshipType::Next => { + Key::Property(JSCalendarProperty::RelationValue( + JSCalendarRelation::Next, + )) + } + other => Key::Borrowed(other.as_str()), + }, + Value::Bool(true), + ); + } + ( + ICalendarParameterName::Reltype, + ICalendarParameterValue::Text(value), + ) => { + rels.insert(Key::Owned(value), Value::Bool(true)); + } + (name, value) => { + entry.entry.params.push(ICalendarParameter { name, value }); + } + } + } + + entry.set_converted_to::(&[ + JSCalendarProperty::RelatedTo::.to_string().as_ref(), + value.as_str(), + ]); + + state + .entries + .entry(Key::Property(JSCalendarProperty::RelatedTo)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert( + Key::Owned(value), + Value::Object(Map::from(if !rels.is_empty() { + vec![( + Key::Property(JSCalendarProperty::Relation), + Value::Object(rels), + )] + } else { + vec![] + })), + ); + } + ( + ICalendarProperty::ShowWithoutTime, + Some(ICalendarValue::Boolean(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::ShowWithoutTime), + Value::Bool(value), + ); + entry.set_converted_to::(&[JSCalendarProperty::ShowWithoutTime:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Status, + Some(ICalendarValue::Status(value)), + ICalendarComponentType::VEvent, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Status), + match value { + ICalendarStatus::Tentative => Value::Element( + JSCalendarValue::EventStatus(JSCalendarEventStatus::Tentative), + ), + ICalendarStatus::Confirmed => Value::Element( + JSCalendarValue::EventStatus(JSCalendarEventStatus::Confirmed), + ), + ICalendarStatus::Cancelled => Value::Element( + JSCalendarValue::EventStatus(JSCalendarEventStatus::Cancelled), + ), + other => Value::Str(other.as_str().into()), + }, + ); + entry.set_converted_to::(&[JSCalendarProperty::Status:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Status, + Some(ICalendarValue::Status(value)), + ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Progress), + match value { + ICalendarStatus::NeedsAction => Value::Element( + JSCalendarValue::Progress(JSCalendarProgress::NeedsAction), + ), + ICalendarStatus::Completed => Value::Element( + JSCalendarValue::Progress(JSCalendarProgress::Completed), + ), + ICalendarStatus::InProcess => Value::Element( + JSCalendarValue::Progress(JSCalendarProgress::InProcess), + ), + ICalendarStatus::Failed => Value::Element(JSCalendarValue::Progress( + JSCalendarProgress::Failed, + )), + ICalendarStatus::Cancelled => Value::Element( + JSCalendarValue::Progress(JSCalendarProgress::Cancelled), + ), + other => Value::Str(other.as_str().into()), + }, + ); + entry.set_converted_to::(&[JSCalendarProperty::Progress:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Status, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VCalendar | ICalendarComponentType::VTodo, + ) => { + let prop = if matches!(state.component_type, ICalendarComponentType::VTodo) { + JSCalendarProperty::Progress + } else { + JSCalendarProperty::Status + }; + + entry.set_converted_to::(&[prop.to_string().as_ref()]); + state + .entries + .insert(Key::Property(prop), Value::Str(value.into())); + } + ( + ICalendarProperty::Source, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VCalendar, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::Source), + Value::Str(value.into()), + ); + entry.set_converted_to::(&[JSCalendarProperty::Source:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Transp, + Some(ICalendarValue::Transparency(value)), + ICalendarComponentType::VEvent | ICalendarComponentType::VTodo, + ) => { + state.entries.insert( + Key::Property(JSCalendarProperty::FreeBusyStatus), + Value::Element(JSCalendarValue::FreeBusyStatus(match value { + ICalendarTransparency::Opaque => JSCalendarFreeBusyStatus::Busy, + ICalendarTransparency::Transparent => JSCalendarFreeBusyStatus::Free, + })), + ); + entry.set_converted_to::(&[JSCalendarProperty::FreeBusyStatus:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Trigger, + Some(ICalendarValue::Duration(value)), + ICalendarComponentType::VAlarm, + ) => { + let mut obj = Map::from(vec![ + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::OffsetTrigger)), + ), + ( + Key::Property(JSCalendarProperty::Offset), + Value::Element(JSCalendarValue::Duration(value)), + ), + ]); + + for param in std::mem::take(&mut entry.entry.params) { + match (param.name, param.value) { + ( + ICalendarParameterName::Related, + ICalendarParameterValue::Related(value), + ) => { + obj.insert( + Key::Property(JSCalendarProperty::RelativeTo), + Value::Element(JSCalendarValue::RelativeTo(match value { + ICalendarRelated::Start => JSCalendarRelativeTo::Start, + ICalendarRelated::End => JSCalendarRelativeTo::End, + })), + ); + } + (name, value) => { + entry.entry.params.push(ICalendarParameter { name, value }); + } + } + } + + state.entries.insert( + Key::Property(JSCalendarProperty::Trigger), + Value::Object(obj), + ); + entry.set_converted_to::(&[JSCalendarProperty::Trigger:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Trigger, + Some(ICalendarValue::PartialDateTime(value)), + ICalendarComponentType::VAlarm, + ) if value.has_date_and_time() => { + state.entries.insert( + Key::Property(JSCalendarProperty::Trigger), + Value::Object(Map::from(vec![ + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type( + JSCalendarType::AbsoluteTrigger, + )), + ), + ( + Key::Property(JSCalendarProperty::When), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + value.to_timestamp().unwrap_or_default(), + false, + ))), + ), + ])), + ); + entry.set_converted_to::(&[JSCalendarProperty::Trigger:: + .to_string() + .as_ref()]); + } + ( + ICalendarProperty::Uid, + Some(ICalendarValue::Text(value)), + ICalendarComponentType::VCalendar + | ICalendarComponentType::VEvent + | ICalendarComponentType::VTodo, + ) => { + state.uid = Some(value); + continue; + } + (ICalendarProperty::Jsid, Some(ICalendarValue::Text(value)), _) => { + state.jsid = Some(value); + continue; + } + (ICalendarProperty::Jsid, _, _) => { + continue; + } + (ICalendarProperty::Jsprop, Some(ICalendarValue::Text(value)), _) => { + if let Some(ICalendarParameter { + name: ICalendarParameterName::Jsptr, + value: ICalendarParameterValue::Text(ptr), + }) = entry.entry.params.first() + { + let ptr = JsonPointer::>::parse(ptr); + + if let Ok(jscalendar) = JSCalendar::parse(&value) { + state.patch_objects.push((ptr, jscalendar.0.into_owned())); + continue; + } + } + entry.entry.values = [ICalendarValue::Text(value)] + .into_iter() + .chain(values) + .collect(); + } + (ICalendarProperty::Begin | ICalendarProperty::End, _, _) => { + continue; + } + + (_, value, _) => { + entry.entry.values = [value].into_iter().flatten().chain(values).collect(); + } + } + + state.add_conversion_props(entry); + } + + if !group_objects.is_empty() { + state.entries.insert( + Key::Property(JSCalendarProperty::Entries), + Value::Array(group_objects), + ); + } + + state + } +} + +fn period_to_date_time( + value: &ICalendarValue, + tz: Tz, +) -> Option<(DateTime, ICalendarDuration)> { + match value { + ICalendarValue::Period(ICalendarPeriod::Range { start, end }) => { + let start = start.to_date_time()?.to_date_time_with_tz(tz)?; + let end = end.to_date_time()?.to_date_time_with_tz(tz)?; + + Some(( + start, + ICalendarDuration::from_seconds(end.signed_duration_since(start).num_seconds()), + )) + } + ICalendarValue::Period(ICalendarPeriod::Duration { start, duration }) => Some(( + start.to_date_time()?.to_date_time_with_tz(tz)?, + duration.clone(), + )), + _ => None, + } +} diff --git a/vendor/calcard/src/jscalendar/import/mod.rs b/vendor/calcard/src/jscalendar/import/mod.rs new file mode 100644 index 00000000..25df8fd5 --- /dev/null +++ b/vendor/calcard/src/jscalendar/import/mod.rs @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use ahash::AHashMap; +use chrono::DateTime; +use jmap_tools::{JsonPointer, Key, Value}; + +use crate::{ + common::timezone::Tz, + icalendar::{ + ICalendarComponentType, ICalendarEntry, ICalendarParameterName, ICalendarProperty, + }, + jscalendar::{JSCalendarId, JSCalendarProperty, JSCalendarValue}, +}; + +pub mod convert; +pub mod params; +pub mod props; + +#[derive(Debug, Clone, Copy)] +pub struct ConversionOptions { + pub include_ical_components: bool, + pub return_first: bool, +} + +#[derive(Default)] +#[allow(clippy::type_complexity)] +struct State { + component_type: ICalendarComponentType, + entries: AHashMap< + Key<'static, JSCalendarProperty>, + Value<'static, JSCalendarProperty, JSCalendarValue>, + >, + ical_converted_properties: AHashMap>, + ical_properties: Vec, JSCalendarValue>>, + ical_components: Option, JSCalendarValue>>, + patch_objects: Vec<( + JsonPointer>, + Value<'static, JSCalendarProperty, JSCalendarValue>, + )>, + jsid: Option, + uid: Option, + recurrence_id: Option>, + tz_start: Option, + tz_end: Option, + has_dates: bool, + map_component: bool, + is_recurrence_instance: bool, + include_ical_components: bool, +} + +#[derive(Debug, Default)] +struct ICalendarConvertedProperty { + name: Option, + params: ICalendarParams, +} + +#[derive(Debug, Default)] +#[allow(clippy::type_complexity)] +struct ICalendarParams( + AHashMap< + ICalendarParameterName, + Vec, JSCalendarValue>>, + >, +); + +#[derive(Debug, Clone)] +struct EntryState { + entry: ICalendarEntry, + converted_to: Option, + map_name: bool, +} + +impl Default for ConversionOptions { + fn default() -> Self { + Self { + include_ical_components: true, + return_first: false, + } + } +} + +impl ConversionOptions { + pub fn include_ical_components(mut self, include: bool) -> Self { + self.include_ical_components = include; + self + } + + pub fn return_first(mut self, return_first: bool) -> Self { + self.return_first = return_first; + self + } +} diff --git a/vendor/calcard/src/jscalendar/import/params.rs b/vendor/calcard/src/jscalendar/import/params.rs new file mode 100644 index 00000000..7b4ef45f --- /dev/null +++ b/vendor/calcard/src/jscalendar/import/params.rs @@ -0,0 +1,423 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{Data, IanaString, IanaType}, + icalendar::{ + ICalendarDisplayType, ICalendarEntry, ICalendarFeatureType, ICalendarParameter, + ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, + ICalendarParticipationStatus, ICalendarProperty, ICalendarRelated, ICalendarUserTypes, + ICalendarValue, ICalendarValueType, Uri, + }, + jscalendar::{ + JSCalendarId, JSCalendarLinkDisplay, JSCalendarParticipantKind, JSCalendarParticipantRole, + JSCalendarParticipationStatus, JSCalendarProperty, JSCalendarRelativeTo, JSCalendarValue, + JSCalendarVirtualLocationFeature, import::ICalendarParams, + }, +}; +use ahash::AHashMap; +use jmap_tools::{Key, Map, Value}; + +pub(super) trait ExtractParams { + fn extract_params( + &mut self, + entry: &mut ICalendarEntry, + extract: &[ICalendarParameterName], + ) -> Option; +} + +impl ExtractParams + for AHashMap< + Key<'static, JSCalendarProperty>, + Value<'static, JSCalendarProperty, JSCalendarValue>, + > +{ + fn extract_params( + &mut self, + entry: &mut ICalendarEntry, + extract: &[ICalendarParameterName], + ) -> Option { + let mut jsid = None; + + for param in std::mem::take(&mut entry.params) { + if !extract.contains(¶m.name) { + entry.params.push(param); + continue; + } + + match param.name { + ICalendarParameterName::Cn => { + if let Some(text) = param.value.into_text() { + self.insert(Key::Property(JSCalendarProperty::Name), Value::Str(text)); + } + } + ICalendarParameterName::Cutype => { + self.insert( + Key::Property(JSCalendarProperty::Kind), + match param.value { + ICalendarParameterValue::Text(value) => Value::Str(value.into()), + ICalendarParameterValue::Cutype(value) => match value { + ICalendarUserTypes::Individual => { + Value::Element(JSCalendarValue::ParticipantKind( + JSCalendarParticipantKind::Individual, + )) + } + ICalendarUserTypes::Group => { + Value::Element(JSCalendarValue::ParticipantKind( + JSCalendarParticipantKind::Group, + )) + } + ICalendarUserTypes::Resource => { + Value::Element(JSCalendarValue::ParticipantKind( + JSCalendarParticipantKind::Resource, + )) + } + ICalendarUserTypes::Room => { + Value::Element(JSCalendarValue::ParticipantKind( + JSCalendarParticipantKind::Location, + )) + } + ICalendarUserTypes::Unknown => { + Value::Str(value.as_str().to_lowercase().into()) + } + }, + _ => continue, + }, + ); + } + ICalendarParameterName::DelegatedFrom => { + if let Some(text) = param.value.into_text() { + self.insert( + Key::Property(JSCalendarProperty::DelegatedFrom), + Value::Str(text), + ); + } + } + ICalendarParameterName::DelegatedTo => { + if let Some(text) = param.value.into_text() { + self.insert( + Key::Property(JSCalendarProperty::DelegatedTo), + Value::Str(text), + ); + } + } + ICalendarParameterName::Email => { + if let Some(text) = param.value.into_text() { + self.insert(Key::Property(JSCalendarProperty::Email), Value::Str(text)); + } + } + ICalendarParameterName::Rsvp => { + if let Some(boolean) = param.value.as_bool() { + self.insert( + Key::Property(JSCalendarProperty::ExpectReply), + Value::Bool(boolean), + ); + } + } + ICalendarParameterName::Member => { + if let Some(text) = param.value.into_text() { + self.entry(Key::Property(JSCalendarProperty::MemberOf)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert(Key::from(text), Value::Bool(true)); + } + } + ICalendarParameterName::Partstat => { + self.insert( + Key::Property(JSCalendarProperty::ParticipationStatus), + match param.value { + ICalendarParameterValue::Partstat(value) => { + Value::Element(JSCalendarValue::ParticipationStatus(match value { + ICalendarParticipationStatus::NeedsAction => { + JSCalendarParticipationStatus::NeedsAction + } + ICalendarParticipationStatus::Declined => { + JSCalendarParticipationStatus::Declined + } + ICalendarParticipationStatus::Tentative => { + JSCalendarParticipationStatus::Tentative + } + ICalendarParticipationStatus::Delegated => { + JSCalendarParticipationStatus::Delegated + } + ICalendarParticipationStatus::Completed + | ICalendarParticipationStatus::InProcess + | ICalendarParticipationStatus::Failed + | ICalendarParticipationStatus::Accepted => { + JSCalendarParticipationStatus::Accepted + } + })) + } + ICalendarParameterValue::Text(value) => Value::Str(value.into()), + _ => continue, + }, + ); + } + ICalendarParameterName::Role => { + let role = match param.value { + ICalendarParameterValue::Role(value) => { + Key::Property(JSCalendarProperty::ParticipantRole(match value { + ICalendarParticipationRole::Chair => { + JSCalendarParticipantRole::Chair + } + ICalendarParticipationRole::ReqParticipant => { + JSCalendarParticipantRole::Required + } + ICalendarParticipationRole::OptParticipant => { + JSCalendarParticipantRole::Optional + } + ICalendarParticipationRole::NonParticipant => { + JSCalendarParticipantRole::Informational + } + ICalendarParticipationRole::Owner => { + JSCalendarParticipantRole::Owner + } + })) + } + ICalendarParameterValue::Text(value) => Key::Owned(value), + _ => continue, + }; + self.entry(Key::Property(JSCalendarProperty::Roles)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert(role, Value::Bool(true)); + } + ICalendarParameterName::SentBy => { + if let Some(text) = param.value.into_text() { + self.insert(Key::Property(JSCalendarProperty::SentBy), Value::Str(text)); + } + } + ICalendarParameterName::Fmttype => { + if let Some(text) = param.value.into_text() { + self.insert( + Key::Property( + if matches!(entry.name, ICalendarProperty::StyledDescription) { + JSCalendarProperty::DescriptionContentType + } else { + JSCalendarProperty::ContentType + }, + ), + Value::Str(text), + ); + } + } + ICalendarParameterName::Label | ICalendarParameterName::Filename => { + if let Some(text) = param.value.into_text() { + self.insert( + Key::Property(if matches!(entry.name, ICalendarProperty::Conference) { + JSCalendarProperty::Name + } else { + JSCalendarProperty::Title + }), + Value::Str(text), + ); + } + } + ICalendarParameterName::Size => { + if let Some(number) = param.value.as_integer() { + self.insert( + Key::Property(JSCalendarProperty::Size), + Value::Number(number.into()), + ); + } else { + entry.params.push(ICalendarParameter::size(param.value)); + } + } + ICalendarParameterName::Linkrel => match param.value { + ICalendarParameterValue::Linkrel(linkrel) => { + self.insert( + Key::Property(JSCalendarProperty::Rel), + Value::Element(JSCalendarValue::LinkRelation(linkrel)), + ); + } + ICalendarParameterValue::Text(value) => { + self.insert( + Key::Property(JSCalendarProperty::Rel), + Value::Str(value.into()), + ); + } + value => { + entry.params.push(ICalendarParameter::linkrel(value)); + } + }, + ICalendarParameterName::Related => { + if let ICalendarParameterValue::Related(related) = param.value { + self.insert( + Key::Property(JSCalendarProperty::RelativeTo), + Value::Element(JSCalendarValue::RelativeTo(match related { + ICalendarRelated::Start => JSCalendarRelativeTo::Start, + ICalendarRelated::End => JSCalendarRelativeTo::End, + })), + ); + } else { + entry.params.push(ICalendarParameter::related(param.value)); + } + } + ICalendarParameterName::Feature => { + let feature = match param.value { + ICalendarParameterValue::Feature(value) => { + Key::Property(JSCalendarProperty::VirtualLocationFeature(match value { + ICalendarFeatureType::Audio => { + JSCalendarVirtualLocationFeature::Audio + } + ICalendarFeatureType::Chat => { + JSCalendarVirtualLocationFeature::Chat + } + ICalendarFeatureType::Feed => { + JSCalendarVirtualLocationFeature::Feed + } + ICalendarFeatureType::Moderator => { + JSCalendarVirtualLocationFeature::Moderator + } + ICalendarFeatureType::Phone => { + JSCalendarVirtualLocationFeature::Phone + } + ICalendarFeatureType::Screen => { + JSCalendarVirtualLocationFeature::Screen + } + ICalendarFeatureType::Video => { + JSCalendarVirtualLocationFeature::Video + } + })) + } + ICalendarParameterValue::Text(value) => Key::Owned(value), + _ => continue, + }; + self.entry(Key::Property(JSCalendarProperty::Features)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert(feature, Value::Bool(true)); + } + ICalendarParameterName::Language => { + if let Some(text) = param.value.into_text() { + self.insert(Key::Property(JSCalendarProperty::Locale), Value::Str(text)); + } + } + ICalendarParameterName::Display => { + let display = match param.value { + ICalendarParameterValue::Display(value) => { + Key::Property(JSCalendarProperty::LinkDisplay(match value { + ICalendarDisplayType::Badge => JSCalendarLinkDisplay::Badge, + ICalendarDisplayType::Graphic => JSCalendarLinkDisplay::Graphic, + ICalendarDisplayType::Fullsize => JSCalendarLinkDisplay::Fullsize, + ICalendarDisplayType::Thumbnail => JSCalendarLinkDisplay::Thumbnail, + })) + } + ICalendarParameterValue::Text(value) => Key::Owned(value), + value => { + entry.params.push(ICalendarParameter::display(value)); + continue; + } + }; + self.entry(Key::Property(JSCalendarProperty::Display)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert(display, Value::Bool(true)); + } + ICalendarParameterName::Jsid => { + jsid = param.value.into_text().map(|v| v.into_owned()); + } + ICalendarParameterName::Range + | ICalendarParameterName::Reltype + | ICalendarParameterName::Dir + | ICalendarParameterName::Fbtype + | ICalendarParameterName::ScheduleAgent + | ICalendarParameterName::ScheduleForceSend + | ICalendarParameterName::ScheduleStatus + | ICalendarParameterName::Tzid + | ICalendarParameterName::Value + | ICalendarParameterName::ManagedId + | ICalendarParameterName::Order + | ICalendarParameterName::Schema + | ICalendarParameterName::Derived + | ICalendarParameterName::Gap + | ICalendarParameterName::Jsptr + | ICalendarParameterName::Other(_) + | ICalendarParameterName::Altrep => {} + } + } + + jsid + } +} + +impl ICalendarParams { + pub(super) fn into_jscalendar_value( + self, + ) -> Option, JSCalendarValue>> { + if !self.0.is_empty() { + let mut obj = Map::from(Vec::with_capacity(self.0.len())); + + for (param, value) in self.0 { + let value = if value.len() > 1 { + Value::Array(value) + } else { + value.into_iter().next().unwrap() + }; + obj.insert_unchecked(Key::Owned(param.into_string().to_ascii_lowercase()), value); + } + Some(obj) + } else { + None + } + } +} + +impl ICalendarValue { + pub(super) fn into_jscalendar_value( + self, + value_type: Option<&IanaType>, + ) -> Value<'static, JSCalendarProperty, JSCalendarValue> { + match self { + ICalendarValue::Text(v) => Value::Str(v.into()), + ICalendarValue::Integer(v) => Value::Number(v.into()), + ICalendarValue::Float(v) => Value::Number(v.into()), + ICalendarValue::Boolean(v) => Value::Bool(v), + ICalendarValue::PartialDateTime(v) => { + let mut out = String::new(); + let _ = v.format_as_ical( + &mut out, + value_type.and_then(|v| v.iana()).unwrap_or( + match (v.has_date(), v.has_time(), v.has_zone()) { + (true, true, _) => &ICalendarValueType::DateTime, + (true, _, _) => &ICalendarValueType::Date, + (_, true, _) => &ICalendarValueType::Time, + (_, _, true) => &ICalendarValueType::UtcOffset, + _ => &ICalendarValueType::Text, + }, + ), + ); + Value::Str(out.into()) + } + ICalendarValue::Binary(v) => Value::Str( + Uri::Data(Data { + content_type: None, + data: v, + }) + .into_unwrapped_string() + .into(), + ), + ICalendarValue::Uri(v) => Value::Str(v.into_unwrapped_string().into()), + ICalendarValue::Duration(v) => Value::Str(v.to_string().into()), + ICalendarValue::RecurrenceRule(v) => Value::Str(v.to_string().into()), + ICalendarValue::Period(v) => Value::Str(v.to_string().into()), + ICalendarValue::CalendarScale(v) => Value::Str(v.as_str().into()), + ICalendarValue::Method(v) => Value::Str(v.as_str().into()), + ICalendarValue::Classification(v) => Value::Str(v.as_str().into()), + ICalendarValue::Status(v) => Value::Str(v.as_str().into()), + ICalendarValue::Transparency(v) => Value::Str(v.as_str().into()), + ICalendarValue::Action(v) => Value::Str(v.as_str().into()), + ICalendarValue::BusyType(v) => Value::Str(v.as_str().into()), + ICalendarValue::ParticipantType(v) => Value::Str(v.as_str().into()), + ICalendarValue::ResourceType(v) => Value::Str(v.as_str().into()), + ICalendarValue::Proximity(v) => Value::Str(v.as_str().into()), + } + } +} diff --git a/vendor/calcard/src/jscalendar/import/props.rs b/vendor/calcard/src/jscalendar/import/props.rs new file mode 100644 index 00000000..372a1a5f --- /dev/null +++ b/vendor/calcard/src/jscalendar/import/props.rs @@ -0,0 +1,491 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{Data, IanaString, IanaType, timezone::TzTimestamp}, + icalendar::{ + ICalendarEntry, ICalendarParameterName, ICalendarProperty, ICalendarValue, + ICalendarValueType, Uri, + }, + jscalendar::{ + JSCalendarDateTime, JSCalendarId, JSCalendarPrivacy, JSCalendarProperty, JSCalendarValue, + import::{ + EntryState, ICalendarConvertedProperty, ICalendarParams, State, params::ExtractParams, + }, + uuid5, + }, +}; +use ahash::AHashMap; +use jmap_tools::{JsonPointer, JsonPointerHandler, JsonPointerItem, Key, Map, Property, Value}; +use std::{borrow::Cow, collections::hash_map::Entry, str::FromStr}; + +impl State { + pub(super) fn map_named_entry( + &mut self, + entry: &mut EntryState, + extract: &[ICalendarParameterName], + top_property_name: JSCalendarProperty, + values: impl IntoIterator< + Item = ( + Key<'static, JSCalendarProperty>, + Value<'static, JSCalendarProperty, JSCalendarValue>, + ), + >, + ) { + self.map_named_entry_with_id(entry, extract, top_property_name, values, None) + } + + pub(super) fn map_named_entry_with_id( + &mut self, + entry: &mut EntryState, + extract: &[ICalendarParameterName], + top_property_name: JSCalendarProperty, + values: impl IntoIterator< + Item = ( + Key<'static, JSCalendarProperty>, + Value<'static, JSCalendarProperty, JSCalendarValue>, + ), + >, + default_id: Option, + ) { + // Obtain main property and value + let mut values = values.into_iter().peekable(); + let (property, value) = match values.peek() { + Some((property, Value::Str(s))) => (property, s.as_ref()), + Some((property, _)) => { + debug_assert!(false, "Cannot generate jsid without a string value"); + (property, "unknown") + } + _ => { + panic!("Cannot generate jsid without a value"); + } + }; + + // Obtain or calculate JSID + let mut parameters = AHashMap::new(); + let js_id = parameters + .extract_params(&mut entry.entry, extract) + .or(default_id) + .unwrap_or_else(|| uuid5(value)); + + // Set converted props + entry.set_converted_to::(&[ + top_property_name.to_cow().as_ref(), + js_id.as_str(), + property.to_string().as_ref(), + ]); + + let obj = self + .get_mut_object_or_insert(top_property_name) + .insert_or_get_mut(Key::Owned(js_id), Value::new_object()) + .as_object_mut() + .unwrap(); + + for (key, value) in values.chain(parameters) { + if let Some(current_value) = obj.get_mut(&key) { + match (value, current_value) { + (Value::Object(new_obj), Value::Object(existing_obj)) => { + for (key, value) in new_obj.into_vec() { + existing_obj.insert(key, value); + } + } + (value, current_value) => { + *current_value = value; + } + } + } else { + obj.insert_unchecked(key, value); + } + } + } + + pub(super) fn insert_recurrence_override( + &mut self, + key: Key<'static, JSCalendarProperty>, + value: Value<'static, JSCalendarProperty, JSCalendarValue>, + ) { + self.entries + .entry(Key::Property(JSCalendarProperty::RecurrenceOverrides)) + .or_insert_with(Value::new_object) + .as_object_mut() + .unwrap() + .insert(key, value); + } + + pub(super) fn add_period_conversion_prop(&mut self, converted_to: String) { + if self.include_ical_components { + let mut params = ICalendarParams::default(); + params.0.insert( + ICalendarParameterName::Value, + vec![Value::Str(ICalendarValueType::Period.as_str().into())], + ); + self.ical_converted_properties + .entry(converted_to) + .or_insert(ICalendarConvertedProperty { + name: Some(ICalendarProperty::Rdate), + params, + }); + } + } + + pub(super) fn add_conversion_props(&mut self, mut entry: EntryState) { + if self.include_ical_components { + if let Some(converted_to) = entry.converted_to.take() { + if entry.map_name || !entry.entry.params.is_empty() { + let mut value_type = None; + + match self.ical_converted_properties.entry(converted_to) { + Entry::Occupied(mut conv_prop) => { + entry.jcal_parameters(&mut conv_prop.get_mut().params, &mut value_type); + } + Entry::Vacant(conv_prop) => { + let mut params = ICalendarParams::default(); + entry.jcal_parameters(&mut params, &mut value_type); + if let Some(value_type) = value_type { + params.0.insert( + ICalendarParameterName::Value, + vec![Value::Str(value_type.into_string())], + ); + } + if !params.0.is_empty() || entry.map_name { + conv_prop.insert(ICalendarConvertedProperty { + name: if entry.map_name { + Some(entry.entry.name) + } else { + None + }, + params, + }); + } + } + } + } + } else { + self.ical_properties.push(entry.into_jcal()); + } + } + } + + pub(super) fn into_object( + mut self, + ) -> Value<'static, JSCalendarProperty, JSCalendarValue> { + let mut ical_obj = Map::from(Vec::new()); + if !self.ical_converted_properties.is_empty() { + let mut converted_properties = + Map::from(Vec::with_capacity(self.ical_converted_properties.len())); + + for (converted_to, props) in self.ical_converted_properties { + let mut obj = Map::from(Vec::with_capacity(2)); + if let Some(params) = props.params.into_jscalendar_value() { + obj.insert( + Key::Property(JSCalendarProperty::Parameters), + Value::Object(params), + ); + } + if let Some(name) = props.name { + obj.insert( + Key::Property(JSCalendarProperty::Name), + Value::Str(name.into_string().to_ascii_lowercase().into()), + ); + } + + converted_properties.insert_unchecked(Key::Owned(converted_to), Value::Object(obj)); + } + + ical_obj.insert_unchecked( + Key::Property(JSCalendarProperty::ConvertedProperties), + Value::Object(converted_properties), + ); + } + + if !self.ical_properties.is_empty() { + ical_obj.insert_unchecked( + Key::Property(JSCalendarProperty::Properties), + Value::Array(self.ical_properties), + ); + } + + if let Some(components) = self.ical_components { + ical_obj.insert_unchecked(Key::Property(JSCalendarProperty::Components), components); + } + + if !ical_obj.is_empty() || self.map_component { + ical_obj.insert_unchecked( + Key::Property(JSCalendarProperty::Name), + Value::Str(self.component_type.as_str().to_ascii_lowercase().into()), + ); + self.entries.insert( + Key::Property(JSCalendarProperty::ICalendar), + Value::Object(ical_obj), + ); + } + + if self.has_dates { + /* + If a date-time value does not have a timezone, then the timezone is not set + in JSCalendar. + */ + + if !self.is_recurrence_instance + && let Some(tz) = self.tz_start.and_then(|tz| tz.name()) + { + self.entries + .insert(Key::Property(JSCalendarProperty::TimeZone), Value::Str(tz)); + } + + if self.tz_end.is_some() + && self.tz_start.is_some() + && self.tz_end != self.tz_start + && let Some(tz) = self.tz_end.and_then(|tz| tz.name()) + { + self.entries.insert( + Key::Property(JSCalendarProperty::EndTimeZone), + Value::Str(tz), + ); + } + + if let Some(recurrence_id) = self.recurrence_id { + self.entries.insert( + Key::Property(JSCalendarProperty::RecurrenceId), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + recurrence_id + .with_timezone(&self.tz_start.unwrap_or_default()) + .to_naive_timestamp(), + true, + ))), + ); + if !self.is_recurrence_instance + && let Some(tz) = recurrence_id + .timezone() + .to_resolved() + .and_then(|tz| tz.name()) + { + self.entries.insert( + Key::Property(JSCalendarProperty::RecurrenceIdTimeZone), + Value::Str(tz), + ); + } + } + } + + if let Some(uid) = self.uid { + self.entries.insert( + Key::Property(JSCalendarProperty::Uid), + Value::Str(uid.into()), + ); + } + + if !self.is_recurrence_instance + && let Some(component_type) = self.component_type.to_jscalendar_type() + { + self.entries.insert( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(component_type)), + ); + } + + let mut obj = Value::Object(self.entries.into_iter().collect()); + if !self.patch_objects.is_empty() { + for (ptr, patch) in self.patch_objects { + obj.patch_jptr(ptr.iter(), patch); + } + } + + obj + } + + pub(super) fn set_map_component(&mut self) { + self.map_component = true; + } + + pub(super) fn set_is_recurrence_instance(&mut self) { + self.is_recurrence_instance = true; + } + + pub(super) fn privacy(&self) -> Option { + match self + .entries + .get(&Key::Property(JSCalendarProperty::Privacy))? + { + Value::Element(JSCalendarValue::Privacy(privacy)) => Some(*privacy), + _ => Some(JSCalendarPrivacy::Private), + } + } + + pub(super) fn raise_privacy(&mut self, privacy: JSCalendarPrivacy) { + if self.privacy().unwrap_or(JSCalendarPrivacy::Public) < privacy { + self.entries.insert( + Key::Property(JSCalendarProperty::Privacy), + Value::Element(JSCalendarValue::Privacy(privacy)), + ); + self.patch_objects.retain(|(pointer, _)| { + !matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + JSCalendarProperty::Privacy + ))) + ) + }); + } + } + + pub(super) fn merge_privacy(&mut self, privacy: JSCalendarPrivacy) { + let privacy = self + .privacy() + .map_or(privacy, |current| current.max(privacy)); + self.entries.insert( + Key::Property(JSCalendarProperty::Privacy), + Value::Element(JSCalendarValue::Privacy(privacy)), + ); + } + + pub(super) fn remove_forbidden_override_patches(&mut self) { + self.entries.retain(|key, _| { + !matches!(key, Key::Property(property) if property.is_forbidden_override_patch()) + }); + self.patch_objects + .retain(|(pointer, _)| !JSCalendarProperty::is_forbidden_override_pointer(pointer)); + self.ical_converted_properties.retain(|converted_to, _| { + !matches!( + converted_to + .split('/') + .next() + .and_then(|name| JSCalendarProperty::::from_str(name).ok()), + Some(JSCalendarProperty::RecurrenceOverrides | JSCalendarProperty::RecurrenceRule) + ) + }); + } + + pub(super) fn find_participant_by_address(&self, address: &str) -> Option { + self.entries + .get(&Key::Property(JSCalendarProperty::Participants))? + .as_object()? + .iter() + .find_map(|(key, value)| { + match value + .as_object()? + .get(&Key::Property(JSCalendarProperty::CalendarAddress))? + { + Value::Str(value) if value == address => Some(key.to_string().into_owned()), + _ => None, + } + }) + } + + #[inline] + pub(super) fn get_mut_object_or_insert( + &mut self, + key: JSCalendarProperty, + ) -> &mut Map<'static, JSCalendarProperty, JSCalendarValue> { + self.entries + .entry(Key::Property(key)) + .or_insert_with(|| Value::Object(Map::from(Vec::new()))) + .as_object_mut() + .unwrap() + } +} + +impl EntryState { + pub(super) fn new(entry: ICalendarEntry) -> Self { + Self { + entry, + converted_to: None, + map_name: false, + } + } + + pub(super) fn set_converted_to(&mut self, converted_to: &[&str]) { + self.converted_to = Some(JsonPointer::>::encode(converted_to)); + } + + pub(super) fn set_map_name(&mut self) { + self.map_name = true; + } + + pub(super) fn into_jcal( + mut self, + ) -> Value<'static, JSCalendarProperty, JSCalendarValue> { + let mut value_type = None; + let mut params = ICalendarParams::default(); + + self.jcal_parameters(&mut params, &mut value_type); + + let values = if self.entry.values.len() == 1 { + self.entry + .values + .into_iter() + .next() + .unwrap() + .into_jscalendar_value(value_type.as_ref()) + } else { + let mut values = Vec::with_capacity(self.entry.values.len()); + for value in self.entry.values { + values.push(value.into_jscalendar_value(value_type.as_ref())); + } + Value::Array(values) + }; + Value::Array(vec![ + Value::Str(self.entry.name.as_str().to_ascii_lowercase().into()), + Value::Object( + params + .into_jscalendar_value() + .unwrap_or(Map::from(Vec::new())), + ), + Value::Str( + value_type + .map(|v| v.into_string()) + .unwrap_or(Cow::Borrowed("unknown")), + ), + values, + ]) + } + + pub(super) fn jcal_parameters( + &mut self, + params: &mut ICalendarParams, + value_type: &mut Option>, + ) { + if self.entry.params.is_empty() { + return; + } + let (default_type, _) = self.entry.name.default_types(); + let default_type = default_type.unwrap_ical(); + + for param in std::mem::take(&mut self.entry.params) { + if matches!(param.name, ICalendarParameterName::Value) { + if let Some(v) = param + .value + .into_value_type() + .filter(|v| !v.is_iana_and(|v| v == &default_type)) + { + *value_type = Some(v); + } + } else if let Some(value) = param.value.into_text() { + params + .0 + .entry(param.name) + .or_default() + .push(Value::Str(value)); + } + } + } +} + +impl ICalendarValue { + pub(super) fn uri_to_string(self) -> Self { + match self { + ICalendarValue::Uri(uri) => ICalendarValue::Text(uri.into_unwrapped_string()), + ICalendarValue::Binary(data) => ICalendarValue::Text( + Uri::Data(Data { + content_type: None, + data, + }) + .into_unwrapped_string(), + ), + other => other, + } + } +} diff --git a/vendor/calcard/src/jscalendar/mod.rs b/vendor/calcard/src/jscalendar/mod.rs new file mode 100644 index 00000000..25c9b908 --- /dev/null +++ b/vendor/calcard/src/jscalendar/mod.rs @@ -0,0 +1,799 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +pub mod export; +pub mod import; +pub mod parser; +pub mod types; + +use crate::{ + common::{CalendarScale, IanaString, LinkRelation}, + icalendar::{ + ICalendarComponentType, ICalendarDuration, ICalendarFrequency, ICalendarMethod, + ICalendarMonth, ICalendarSkip, ICalendarWeekday, + }, +}; +use jmap_tools::{JsonPointer, Key, Map, Value}; +use mail_parser::DateTime; +use serde::Serialize; +use std::{borrow::Cow, fmt::Debug, fmt::Display, hash::Hash, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[repr(transparent)] +pub struct JSCalendar<'x, I: JSCalendarId, B: JSCalendarId>( + pub Value<'x, JSCalendarProperty, JSCalendarValue>, +); + +pub trait JSCalendarId: + FromStr + Sized + Serialize + Display + Clone + Eq + Hash + Ord + Debug + Default +{ +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarValue { + Type(JSCalendarType), + DateTime(JSCalendarDateTime), + Duration(ICalendarDuration), + AlertAction(JSCalendarAlertAction), + FreeBusyStatus(JSCalendarFreeBusyStatus), + ParticipantKind(JSCalendarParticipantKind), + ParticipationStatus(JSCalendarParticipationStatus), + Privacy(JSCalendarPrivacy), + Progress(JSCalendarProgress), + RelativeTo(JSCalendarRelativeTo), + ScheduleAgent(JSCalendarScheduleAgent), + EventStatus(JSCalendarEventStatus), + LinkRelation(LinkRelation), + Frequency(ICalendarFrequency), + CalendarScale(CalendarScale), + Skip(ICalendarSkip), + Weekday(ICalendarWeekday), + Month(ICalendarMonth), + Method(ICalendarMethod), + Id(I), + BlobId(B), + IdReference(String), +} + +impl Default for JSCalendarValue { + fn default() -> Self { + JSCalendarValue::Type(JSCalendarType::default()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct JSCalendarDateTime { + pub timestamp: i64, + pub is_local: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarProperty { + // JMAP for Calendar Properties + Id, + BaseEventId, + CalendarIds, + IsDraft, + IsOrigin, + UtcStart, + UtcEnd, + UseDefaultAlerts, + MayInviteSelf, + MayInviteOthers, + HideAttendees, + BlobId, + + // JSCalendar Properties + #[default] + Type, + Acknowledged, + Action, + Alerts, + ByDay, + ByHour, + ByMinute, + ByMonth, + ByMonthDay, + BySecond, + BySetPosition, + ByWeekNo, + ByYearDay, + CalendarAddress, + Categories, + Color, + ContentType, + Coordinates, + Count, + Created, + Day, + DelegatedFrom, + DelegatedTo, + Description, + DescriptionContentType, + Display, + Due, + Duration, + Email, + Entries, + EstimatedDuration, + Excluded, + ExpectReply, + Features, + FirstDayOfWeek, + FreeBusyStatus, + Frequency, + Href, + Interval, + InvitedBy, + Keywords, + Kind, + Links, + Locale, + Locations, + LocationTypes, + MemberOf, + Method, + Name, + NthOfPeriod, + Offset, + Participants, + ParticipationComment, + ParticipationStatus, + PercentComplete, + Priority, + Privacy, + ProdId, + Progress, + RecurrenceId, + RecurrenceIdTimeZone, + RecurrenceOverrides, + Rel, + RelatedTo, + Relation, + RelativeTo, + ReplyTo, + RequestStatus, + Roles, + Rscale, + SentBy, + ScheduleAgent, + ScheduleForceSend, + ScheduleSequence, + ScheduleStatus, + ScheduleUpdated, + SendTo, + Sequence, + ShowWithoutTime, + Size, + Skip, + Source, + Start, + Status, + TimeZone, + Title, + Trigger, + Uid, + Until, + Updated, + Uri, + Version, + VirtualLocations, + When, + EndTimeZone, + MainLocationId, + OrganizerCalendarAddress, + RecurrenceRule, + ICalendar, + Properties, + Parameters, + ConvertedProperties, + ValueType, + Components, + DateTime(JSCalendarDateTime), + LinkDisplay(JSCalendarLinkDisplay), + VirtualLocationFeature(JSCalendarVirtualLocationFeature), + ParticipantRole(JSCalendarParticipantRole), + RelationValue(JSCalendarRelation), + LinkRelation(LinkRelation), + Pointer(JsonPointer>), + IdValue(I), + IdReference(String), +} + +impl JSCalendarId for T where + T: FromStr + Sized + Serialize + Display + Clone + Eq + Hash + Ord + Debug + Default +{ +} + +impl From for JSCalendarProperty { + fn from(id: I) -> Self { + JSCalendarProperty::IdValue(id) + } +} + +impl From for JSCalendarValue { + fn from(id: I) -> Self { + JSCalendarValue::Id(id) + } +} + +impl<'x, I: JSCalendarId, B: JSCalendarId> Default for JSCalendar<'x, I, B> { + fn default() -> Self { + Self(Value::Object(Map::from(vec![ + ( + Key::Property(JSCalendarProperty::Type), + Value::Element(JSCalendarValue::Type(JSCalendarType::Group)), + ), + ( + Key::Property(JSCalendarProperty::Entries), + Value::Array(vec![]), + ), + ]))) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarType { + #[default] + Event, + Task, + Group, + Alert, + Boolean, + Duration, + Id, + Int, + LocalDateTime, + Link, + Location, + NDay, + Number, + Participant, + PatchObject, + RecurrenceRule, + Relation, + SignedDuration, + String, + TimeZone, + TimeZoneId, + TimeZoneRule, + UnsignedInt, + UTCDateTime, + VirtualLocation, + ICalComponent, + OffsetTrigger, + AbsoluteTrigger, +} + +// JSCalendar Enum Values for action (Context: Alert) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarAlertAction { + Display, + Email, +} + +// JSCalendar Enum Values for display (Context: Link) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarLinkDisplay { + Badge, + Graphic, + Fullsize, + Thumbnail, +} + +// JSCalendar Enum Values for features (Context: VirtualLocation) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarVirtualLocationFeature { + Audio, + Chat, + Feed, + Moderator, + Phone, + Screen, + Video, +} + +// JSCalendar Enum Values for freeBusyStatus (Context: Event, Task) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarFreeBusyStatus { + Free, + Busy, +} + +// JSCalendar Enum Values for kind (Context: Participant) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarParticipantKind { + Individual, + Group, + Resource, + Location, +} + +// JSCalendar Enum Values for participationStatus (Context: Participant) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarParticipationStatus { + NeedsAction, + Accepted, + Declined, + Tentative, + Delegated, +} + +// JSCalendar Enum Values for privacy (Context: Event, Task) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarPrivacy { + Public, + Private, + Secret, +} + +// JSCalendar Enum Values for progress (Context: Task, Participant) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarProgress { + NeedsAction, + InProcess, + Completed, + Failed, + Cancelled, +} + +// JSCalendar Enum Values for relation (Context: Relation) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarRelation { + First, + Next, + Child, + Parent, + Snooze, +} + +// JSCalendar Enum Values for relativeTo (Context: OffsetTrigger, Location) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarRelativeTo { + Start, + End, +} + +// JSCalendar Enum Values for roles (Context: Participant) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarParticipantRole { + Owner, + Optional, + Informational, + Chair, + Required, +} + +// JSCalendar Enum Values for scheduleAgent (Context: Participant) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarScheduleAgent { + Server, + Client, + None, +} + +// JSCalendar Enum Values for status (Context: Event) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSCalendarEventStatus { + Confirmed, + Cancelled, + Tentative, +} + +impl JSCalendarDateTime { + pub fn new(timestamp: i64, is_local: bool) -> Self { + Self { + timestamp, + is_local, + } + } + + pub fn is_start_of_day(&self) -> bool { + self.timestamp.rem_euclid(86400) == 0 + } + + pub fn to_rfc3339(&self) -> String { + let dt = DateTime::from_timestamp(self.timestamp); + if !self.is_local { + dt.to_rfc3339() + } else { + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, + ) + } + } +} + +impl ICalendarComponentType { + pub fn to_jscalendar_type(&self) -> Option { + match &self { + ICalendarComponentType::VCalendar => Some(JSCalendarType::Group), + ICalendarComponentType::VEvent => Some(JSCalendarType::Event), + ICalendarComponentType::VTodo => Some(JSCalendarType::Task), + ICalendarComponentType::VAlarm => Some(JSCalendarType::Alert), + ICalendarComponentType::Participant => Some(JSCalendarType::Participant), + ICalendarComponentType::VLocation => Some(JSCalendarType::Location), + ICalendarComponentType::Standard + | ICalendarComponentType::Daylight + | ICalendarComponentType::VAvailability + | ICalendarComponentType::Available + | ICalendarComponentType::VResource + | ICalendarComponentType::VStatus + | ICalendarComponentType::VJournal + | ICalendarComponentType::VFreebusy + | ICalendarComponentType::VTimezone + | ICalendarComponentType::Other(_) => None, + } + } +} + +impl JSCalendarType { + pub fn to_icalendar_component_type(&self) -> Option { + match &self { + JSCalendarType::Group => Some(ICalendarComponentType::VCalendar), + JSCalendarType::Event => Some(ICalendarComponentType::VEvent), + JSCalendarType::Task => Some(ICalendarComponentType::VTodo), + JSCalendarType::Alert => Some(ICalendarComponentType::VAlarm), + JSCalendarType::Participant => Some(ICalendarComponentType::Participant), + JSCalendarType::Location => Some(ICalendarComponentType::VLocation), + _ => None, + } + } +} + +// 7f1e1965-ae73-4454-b088-232c90730ce2 +static JSCAL_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([ + 127, 30, 25, 101, 174, 115, 68, 84, 176, 136, 35, 44, 144, 115, 12, 226, +]); + +#[inline] +pub(crate) fn uuid5(text: impl AsRef<[u8]>) -> String { + uuid::Uuid::new_v5(&JSCAL_NAMESPACE, text.as_ref()) + .hyphenated() + .to_string() +} + +#[cfg(test)] +impl std::fmt::Display for JSCalendar<'_, I, B> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = serde_json::to_string_pretty(&self.0).map_err(|_| std::fmt::Error)?; + write!(f, "{}", s) + } +} + +impl Display for JSCalendarProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.to_string().as_ref()) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + icalendar::{ICalendar, ICalendarComponent, ICalendarProperty}, + jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue}, + }; + use jmap_tools::Value; + + #[derive(Debug, Default)] + struct Test { + comment: String, + test: String, + expect: String, + roundtrip: String, + line_num: usize, + } + + #[test] + fn export_version_property() { + let js = JSCalendar::::parse( + r#"{"@type": "Group", "version": "2.0", "prodId": "-//Example//EN", + "entries": [{"@type": "Event", "uid": "a", "title": "b"}]}"#, + ) + .unwrap(); + let ical = js.into_icalendar().unwrap().to_string(); + + assert!(ical.contains("VERSION:2.0"), "{ical}"); + assert!(ical.contains("PRODID:-//Example//EN"), "{ical}"); + assert!(!ical.contains("JSPROP"), "{ical}"); + + // The VERSION property restored from the iCalendar property is not duplicated + let ical = ICalendar::parse(concat!( + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:b\r\nEND:VEVENT\r\n", + "END:VCALENDAR\r\n" + )) + .unwrap() + .into_jscalendar::() + .into_icalendar() + .unwrap() + .to_string(); + + assert_eq!(ical.matches("VERSION:2.0").count(), 1, "{ical}"); + } + + #[test] + fn convert_jscalendar() { + // Read all test files in the test directory + for entry in std::fs::read_dir("resources/jscalendar").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let input = std::fs::read_to_string(&path).unwrap(); + let mut test = Test::default(); + let mut cur_command = ""; + let mut cur_value = &mut test.test; + + for (line_num, line) in input.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + if let Some(line) = line.strip_prefix("> ") { + let (command, comment) = line.split_once(' ').unwrap_or((line, "")); + + match (command, cur_command) { + ("test", _) => { + if !test.test.is_empty() { + test.run(); + } + test = Test::default(); + cur_value = &mut test.test; + cur_command = "test"; + test.comment = comment.to_string(); + test.line_num = line_num + 1; + } + ("convert", "test") => { + cur_command = "convert"; + cur_value = &mut test.expect; + } + ("convert", "convert") => { + cur_command = "convert"; + cur_value = &mut test.roundtrip; + } + _ => { + panic!( + "Unexpected command '{}' in file '{}' at line {}", + command, + path.display(), + line_num + 1 + ); + } + } + } else { + cur_value.push_str(line); + cur_value.push('\n'); + } + } + + if !test.test.is_empty() { + test.run(); + } + } + } + + impl Test { + fn run(mut self) { + if self.expect.is_empty() { + panic!( + "Test '{}' at line {} has no expected output", + self.comment, self.line_num + ); + } + + println!("Running test '{}' at line {}", self.comment, self.line_num); + + if is_jscalendar(&self.test) { + fix_jscalendar(&mut self.test); + fix_icalendar(&mut self.expect); + let source = + sanitize_jscalendar(parse_jscalendar(&self.comment, self.line_num, &self.test)); + let expect = + sanitize_icalendar(parse_icalendar(&self.comment, self.line_num, &self.expect)); + let roundtrip = if !self.roundtrip.is_empty() { + fix_jscalendar(&mut self.roundtrip); + sanitize_jscalendar(parse_jscalendar( + &self.comment, + self.line_num, + &self.roundtrip, + )) + } else { + source.clone() + }; + + let first_convert = + sanitize_icalendar(source.into_icalendar().unwrap_or_else(|| { + panic!( + "Failed to convert JSCalendar to iCalendar: test {} on line {}: {}", + self.comment, self.line_num, self.test + ) + })); + if first_convert != expect { + let first_convert = + sanitize_icalendar(ICalendar::parse(first_convert.to_string()).unwrap()); + + if first_convert != expect { + panic!( + "JSCalendar to iCalendar conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, expect, first_convert + ); + } + } + let roundtrip_convert = sanitize_jscalendar(first_convert.into_jscalendar()); + if roundtrip_convert != roundtrip { + let roundtrip_convert = roundtrip_convert.to_string(); + let roundtrip = roundtrip.to_string(); + + if roundtrip_convert != roundtrip { + panic!( + "iCalendar to JSCalendar conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, roundtrip, roundtrip_convert + ); + } + } + } else { + fix_icalendar(&mut self.test); + fix_jscalendar(&mut self.expect); + let source = + sanitize_icalendar(parse_icalendar(&self.comment, self.line_num, &self.test)); + let expect = sanitize_jscalendar(parse_jscalendar( + &self.comment, + self.line_num, + &self.expect, + )); + let roundtrip = if !self.roundtrip.is_empty() { + fix_icalendar(&mut self.roundtrip); + sanitize_icalendar(parse_icalendar( + &self.comment, + self.line_num, + &self.roundtrip, + )) + } else { + source.clone() + }; + + let first_convert = sanitize_jscalendar(source.into_jscalendar()); + if first_convert != expect { + let first_convert = first_convert.to_string(); + let expect = expect.to_string(); + + if first_convert != expect { + panic!( + "iCalendar to JSCalendar conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, expect, first_convert + ); + } + } + let roundtrip_convert = + sanitize_icalendar(first_convert.into_icalendar().unwrap_or_else(|| { + panic!( + "Failed to convert JSCalendar to iCalendar: test {} on line {}: {}", + self.comment, self.line_num, self.test + ) + })); + if roundtrip_convert != roundtrip { + let roundtrip_convert = sanitize_icalendar( + ICalendar::parse(roundtrip_convert.to_string()).unwrap(), + ); + if roundtrip_convert != roundtrip { + panic!( + "JSCalendar to iCalendar conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, roundtrip, roundtrip_convert + ); + } + } + } + } + } + + fn is_jscalendar(s: &str) -> bool { + s.starts_with("{") || s.starts_with("\"") + } + + fn fix_icalendar(s: &mut String) { + if s.starts_with("BEGIN:") { + if !s.starts_with("BEGIN:VCALENDAR") { + let mut v = "BEGIN:VCALENDAR\nVERSION:2.0\n".to_string(); + v.push_str(s); + v.push_str("END:VCALENDAR\n"); + *s = v; + } + } else { + let mut v = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\n".to_string(); + v.push_str(s); + v.push_str("END:VEVENT\nEND:VCALENDAR\n"); + *s = v; + } + } + + fn fix_jscalendar(s: &mut String) { + let (prefix, suffix) = if !s.starts_with("{") { + ("{", "}") + } else { + ("", "") + }; + + if s.contains(r#""@type": "Group""#) { + if !prefix.is_empty() { + *s = format!("{prefix}{s}{suffix}"); + } + } else if s.contains(r#""@type": "Event""#) || s.contains(r#""@type": "Task""#) { + *s = format!("{{\"@type\": \"Group\", \"entries\": [{prefix}{s}{suffix}]}}"); + } else { + *s = format!( + "{{\"@type\": \"Group\", \"entries\": [{prefix}\"@type\": \"Event\", {s}{suffix}]}}" + ); + } + } + + fn parse_icalendar(test_name: &str, line_num: usize, s: &str) -> ICalendar { + ICalendar::parse(s).unwrap_or_else(|_| { + panic!( + "Failed to parse iCalendar: {} on line {}, test {}", + s, line_num, test_name + ) + }) + } + + fn parse_jscalendar<'x>( + test_name: &str, + line_num: usize, + s: &'x str, + ) -> JSCalendar<'x, String, String> { + JSCalendar::parse(s).unwrap_or_else(|_| { + panic!( + "Failed to parse JSCalendar: {} on line {}, test {}", + s, line_num, test_name + ) + }) + } + + fn sanitize_icalendar(mut icalendar: ICalendar) -> ICalendar { + for component in &mut icalendar.components { + sanitize_icalendar_component(component); + } + + icalendar + } + + fn sanitize_icalendar_component(component: &mut ICalendarComponent) { + component + .entries + .retain(|e| !matches!(e.name, ICalendarProperty::Version)); + component + .entries + .sort_unstable_by(|a, b| a.name.cmp(&b.name)); + } + + fn sanitize_jscalendar( + mut jscalendar: JSCalendar<'_, String, String>, + ) -> JSCalendar<'_, String, String> { + sort_jscalendar_properties(&mut jscalendar.0); + jscalendar + } + + fn sort_jscalendar_properties( + value: &mut Value<'_, JSCalendarProperty, JSCalendarValue>, + ) { + match value { + Value::Array(value) => { + for item in value { + sort_jscalendar_properties(item); + } + } + Value::Object(obj) => { + //obj.as_mut_vec() + // .retain(|(k, _)| !matches!(k, Key::Property(JSCalendarProperty::Type))); + obj.as_mut_vec() + .sort_unstable_by(|a, b| a.0.to_string().cmp(&b.0.to_string())); + for (_, item) in obj.as_mut_vec() { + sort_jscalendar_properties(item); + } + } + _ => {} + } + } +} diff --git a/vendor/calcard/src/jscalendar/parser.rs b/vendor/calcard/src/jscalendar/parser.rs new file mode 100644 index 00000000..1424e627 --- /dev/null +++ b/vendor/calcard/src/jscalendar/parser.rs @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{CalendarScale, IanaParse, IanaString, IdReference, LinkRelation}, + icalendar::{ + ICalendarDuration, ICalendarFrequency, ICalendarMethod, ICalendarMonth, ICalendarSkip, + ICalendarWeekday, + }, + jscalendar::{ + JSCalendar, JSCalendarAlertAction, JSCalendarDateTime, JSCalendarEventStatus, + JSCalendarFreeBusyStatus, JSCalendarId, JSCalendarLinkDisplay, JSCalendarParticipantKind, + JSCalendarParticipantRole, JSCalendarParticipationStatus, JSCalendarPrivacy, + JSCalendarProgress, JSCalendarProperty, JSCalendarRelation, JSCalendarRelativeTo, + JSCalendarScheduleAgent, JSCalendarType, JSCalendarValue, JSCalendarVirtualLocationFeature, + }, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Value}; +use mail_parser::DateTime; +use std::{borrow::Cow, str::FromStr}; + +impl<'x, I: JSCalendarId, B: JSCalendarId> JSCalendar<'x, I, B> { + pub fn parse(json: &'x str) -> Result { + Value::parse_json(json).map(JSCalendar) + } + + pub fn to_string_pretty(&self) -> String { + serde_json::to_string_pretty(&self.0).unwrap_or_default() + } +} + +impl Element for JSCalendarValue { + type Property = JSCalendarProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + JSCalendarProperty::Type => JSCalendarType::from_str(value) + .ok() + .map(JSCalendarValue::Type), + JSCalendarProperty::Created + | JSCalendarProperty::Updated + | JSCalendarProperty::Acknowledged + | JSCalendarProperty::ScheduleUpdated + | JSCalendarProperty::When + | JSCalendarProperty::UtcStart + | JSCalendarProperty::UtcEnd => DateTime::parse_rfc3339(value).map(|dt| { + JSCalendarValue::DateTime(JSCalendarDateTime { + timestamp: dt.to_timestamp_local(), + is_local: false, + }) + }), + JSCalendarProperty::Due + | JSCalendarProperty::RecurrenceId + | JSCalendarProperty::Start + | JSCalendarProperty::Until => DateTime::parse_rfc3339(value).map(|dt| { + JSCalendarValue::DateTime(JSCalendarDateTime { + timestamp: dt.to_timestamp_local(), + is_local: true, + }) + }), + JSCalendarProperty::Duration + | JSCalendarProperty::EstimatedDuration + | JSCalendarProperty::Offset => { + ICalendarDuration::parse(value.as_bytes()).map(JSCalendarValue::Duration) + } + JSCalendarProperty::Action => JSCalendarAlertAction::from_str(value) + .ok() + .map(JSCalendarValue::AlertAction), + JSCalendarProperty::FreeBusyStatus => JSCalendarFreeBusyStatus::from_str(value) + .ok() + .map(JSCalendarValue::FreeBusyStatus), + JSCalendarProperty::Kind => JSCalendarParticipantKind::from_str(value) + .ok() + .map(JSCalendarValue::ParticipantKind), + JSCalendarProperty::ParticipationStatus => { + JSCalendarParticipationStatus::from_str(value) + .ok() + .map(JSCalendarValue::ParticipationStatus) + } + JSCalendarProperty::Privacy => JSCalendarPrivacy::from_str(value) + .ok() + .map(JSCalendarValue::Privacy), + JSCalendarProperty::Progress => JSCalendarProgress::from_str(value) + .ok() + .map(JSCalendarValue::Progress), + JSCalendarProperty::RelativeTo => JSCalendarRelativeTo::from_str(value) + .ok() + .map(JSCalendarValue::RelativeTo), + JSCalendarProperty::ScheduleAgent => JSCalendarScheduleAgent::from_str(value) + .ok() + .map(JSCalendarValue::ScheduleAgent), + JSCalendarProperty::Status => JSCalendarEventStatus::from_str(value) + .ok() + .map(JSCalendarValue::EventStatus), + JSCalendarProperty::Rel => { + LinkRelation::parse(value.as_bytes()).map(JSCalendarValue::LinkRelation) + } + JSCalendarProperty::Frequency => { + ICalendarFrequency::parse(value.as_bytes()).map(JSCalendarValue::Frequency) + } + JSCalendarProperty::FirstDayOfWeek | JSCalendarProperty::Day => { + ICalendarWeekday::parse(value.as_bytes()).map(JSCalendarValue::Weekday) + } + JSCalendarProperty::Skip => { + ICalendarSkip::parse(value.as_bytes()).map(JSCalendarValue::Skip) + } + JSCalendarProperty::Rscale => { + CalendarScale::parse(value.as_bytes()).map(JSCalendarValue::CalendarScale) + } + JSCalendarProperty::ByMonth => { + ICalendarMonth::parse(value.as_bytes()).map(JSCalendarValue::Month) + } + JSCalendarProperty::Method => { + ICalendarMethod::parse(value.as_bytes()).map(JSCalendarValue::Method) + } + JSCalendarProperty::Id | JSCalendarProperty::BaseEventId => { + match IdReference::parse(value) { + IdReference::Value(value) => JSCalendarValue::Id(value).into(), + IdReference::Reference(value) => JSCalendarValue::IdReference(value).into(), + IdReference::Error => None, + } + } + JSCalendarProperty::BlobId => match IdReference::parse(value) { + IdReference::Value(value) => JSCalendarValue::BlobId(value).into(), + IdReference::Reference(value) => JSCalendarValue::IdReference(value).into(), + IdReference::Error => None, + }, + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + JSCalendarValue::Type(v) => v.as_str().into(), + JSCalendarValue::DateTime(v) => v.to_rfc3339().into(), + JSCalendarValue::Duration(v) => v.to_string().into(), + JSCalendarValue::AlertAction(v) => v.as_str().into(), + JSCalendarValue::FreeBusyStatus(v) => v.as_str().into(), + JSCalendarValue::ParticipantKind(v) => v.as_str().into(), + JSCalendarValue::ParticipationStatus(v) => v.as_str().into(), + JSCalendarValue::Privacy(v) => v.as_str().into(), + JSCalendarValue::Progress(v) => v.as_str().into(), + JSCalendarValue::RelativeTo(v) => v.as_str().into(), + JSCalendarValue::ScheduleAgent(v) => v.as_str().into(), + JSCalendarValue::EventStatus(v) => v.as_str().into(), + JSCalendarValue::LinkRelation(v) => v.as_str().into(), + JSCalendarValue::Frequency(v) => v.as_js_str().into(), + JSCalendarValue::CalendarScale(v) => v.as_js_str().into(), + JSCalendarValue::Skip(v) => v.as_js_str().into(), + JSCalendarValue::Weekday(v) => v.as_js_str().into(), + JSCalendarValue::Month(v) => v.to_string().into(), + JSCalendarValue::Method(v) => v.as_js_str().into(), + JSCalendarValue::Id(v) => v.to_string().into(), + JSCalendarValue::BlobId(v) => v.to_string().into(), + JSCalendarValue::IdReference(s) => format!("#{}", s).into(), + } + } +} + +impl jmap_tools::Property for JSCalendarProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + match key { + Some(Key::Property(key)) => match key.patch_or_prop() { + JSCalendarProperty::RecurrenceOverrides => { + DateTime::parse_rfc3339(value).map(|dt| { + JSCalendarProperty::DateTime(JSCalendarDateTime { + timestamp: dt.to_timestamp_local(), + is_local: true, + }) + }) + } + JSCalendarProperty::Display => JSCalendarLinkDisplay::from_str(value) + .ok() + .map(JSCalendarProperty::LinkDisplay), + JSCalendarProperty::Features => JSCalendarVirtualLocationFeature::from_str(value) + .ok() + .map(JSCalendarProperty::VirtualLocationFeature), + JSCalendarProperty::Roles => JSCalendarParticipantRole::from_str(value) + .ok() + .map(JSCalendarProperty::ParticipantRole), + JSCalendarProperty::Relation => JSCalendarRelation::from_str(value) + .ok() + .map(JSCalendarProperty::RelationValue), + JSCalendarProperty::ConvertedProperties => { + JSCalendarProperty::Pointer(JsonPointer::parse(value)).into() + } + JSCalendarProperty::DateTime(_) if value.contains('/') => { + JSCalendarProperty::Pointer(JsonPointer::parse(value)).into() + } + JSCalendarProperty::CalendarIds => match IdReference::parse(value) { + IdReference::Value(value) => JSCalendarProperty::IdValue(value).into(), + IdReference::Reference(value) => JSCalendarProperty::IdReference(value).into(), + IdReference::Error => None, + }, + _ => JSCalendarProperty::from_str(value).ok(), + }, + None if value.contains('/') => { + JSCalendarProperty::Pointer(JsonPointer::parse(value)).into() + } + _ => JSCalendarProperty::from_str(value).ok(), + } + } + + fn to_cow(&self) -> Cow<'static, str> { + self.to_string() + } +} + +impl JSCalendarProperty { + fn patch_or_prop(&self) -> &JSCalendarProperty { + if let JSCalendarProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} diff --git a/vendor/calcard/src/jscalendar/types.rs b/vendor/calcard/src/jscalendar/types.rs new file mode 100644 index 00000000..53ef217d --- /dev/null +++ b/vendor/calcard/src/jscalendar/types.rs @@ -0,0 +1,756 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::jscalendar::*; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use std::str::FromStr; + +impl FromStr for JSCalendarProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "@type" => JSCalendarProperty::Type, + "acknowledged" => JSCalendarProperty::Acknowledged, + "action" => JSCalendarProperty::Action, + "alerts" => JSCalendarProperty::Alerts, + "baseEventId" => JSCalendarProperty::BaseEventId, + "byDay" => JSCalendarProperty::ByDay, + "byHour" => JSCalendarProperty::ByHour, + "byMinute" => JSCalendarProperty::ByMinute, + "byMonth" => JSCalendarProperty::ByMonth, + "byMonthDay" => JSCalendarProperty::ByMonthDay, + "bySecond" => JSCalendarProperty::BySecond, + "bySetPosition" => JSCalendarProperty::BySetPosition, + "byWeekNo" => JSCalendarProperty::ByWeekNo, + "byYearDay" => JSCalendarProperty::ByYearDay, + "calendarAddress" => JSCalendarProperty::CalendarAddress, + "calendarIds" => JSCalendarProperty::CalendarIds, + "categories" => JSCalendarProperty::Categories, + "color" => JSCalendarProperty::Color, + "contentType" => JSCalendarProperty::ContentType, + "coordinates" => JSCalendarProperty::Coordinates, + "count" => JSCalendarProperty::Count, + "created" => JSCalendarProperty::Created, + "day" => JSCalendarProperty::Day, + "delegatedFrom" => JSCalendarProperty::DelegatedFrom, + "delegatedTo" => JSCalendarProperty::DelegatedTo, + "description" => JSCalendarProperty::Description, + "descriptionContentType" => JSCalendarProperty::DescriptionContentType, + "display" => JSCalendarProperty::Display, + "due" => JSCalendarProperty::Due, + "duration" => JSCalendarProperty::Duration, + "email" => JSCalendarProperty::Email, + "entries" => JSCalendarProperty::Entries, + "estimatedDuration" => JSCalendarProperty::EstimatedDuration, + "excluded" => JSCalendarProperty::Excluded, + "expectReply" => JSCalendarProperty::ExpectReply, + "features" => JSCalendarProperty::Features, + "firstDayOfWeek" => JSCalendarProperty::FirstDayOfWeek, + "freeBusyStatus" => JSCalendarProperty::FreeBusyStatus, + "frequency" => JSCalendarProperty::Frequency, + "hideAttendees" => JSCalendarProperty::HideAttendees, + "href" => JSCalendarProperty::Href, + "id" => JSCalendarProperty::Id, + "interval" => JSCalendarProperty::Interval, + "invitedBy" => JSCalendarProperty::InvitedBy, + "isDraft" => JSCalendarProperty::IsDraft, + "isOrigin" => JSCalendarProperty::IsOrigin, + "keywords" => JSCalendarProperty::Keywords, + "kind" => JSCalendarProperty::Kind, + "links" => JSCalendarProperty::Links, + "locale" => JSCalendarProperty::Locale, + "locations" => JSCalendarProperty::Locations, + "locationTypes" => JSCalendarProperty::LocationTypes, + "mayInviteOthers" => JSCalendarProperty::MayInviteOthers, + "mayInviteSelf" => JSCalendarProperty::MayInviteSelf, + "memberOf" => JSCalendarProperty::MemberOf, + "method" => JSCalendarProperty::Method, + "name" => JSCalendarProperty::Name, + "nthOfPeriod" => JSCalendarProperty::NthOfPeriod, + "offset" => JSCalendarProperty::Offset, + "participants" => JSCalendarProperty::Participants, + "participationComment" => JSCalendarProperty::ParticipationComment, + "participationStatus" => JSCalendarProperty::ParticipationStatus, + "percentComplete" => JSCalendarProperty::PercentComplete, + "priority" => JSCalendarProperty::Priority, + "privacy" => JSCalendarProperty::Privacy, + "prodId" => JSCalendarProperty::ProdId, + "progress" => JSCalendarProperty::Progress, + "recurrenceId" => JSCalendarProperty::RecurrenceId, + "recurrenceIdTimeZone" => JSCalendarProperty::RecurrenceIdTimeZone, + "recurrenceOverrides" => JSCalendarProperty::RecurrenceOverrides, + "rel" => JSCalendarProperty::Rel, + "relatedTo" => JSCalendarProperty::RelatedTo, + "relation" => JSCalendarProperty::Relation, + "relativeTo" => JSCalendarProperty::RelativeTo, + "replyTo" => JSCalendarProperty::ReplyTo, + "requestStatus" => JSCalendarProperty::RequestStatus, + "roles" => JSCalendarProperty::Roles, + "rscale" => JSCalendarProperty::Rscale, + "sentBy" => JSCalendarProperty::SentBy, + "scheduleAgent" => JSCalendarProperty::ScheduleAgent, + "scheduleForceSend" => JSCalendarProperty::ScheduleForceSend, + "scheduleSequence" => JSCalendarProperty::ScheduleSequence, + "scheduleStatus" => JSCalendarProperty::ScheduleStatus, + "scheduleUpdated" => JSCalendarProperty::ScheduleUpdated, + "sendTo" => JSCalendarProperty::SendTo, + "sequence" => JSCalendarProperty::Sequence, + "showWithoutTime" => JSCalendarProperty::ShowWithoutTime, + "size" => JSCalendarProperty::Size, + "skip" => JSCalendarProperty::Skip, + "source" => JSCalendarProperty::Source, + "start" => JSCalendarProperty::Start, + "status" => JSCalendarProperty::Status, + "timeZone" => JSCalendarProperty::TimeZone, + "title" => JSCalendarProperty::Title, + "trigger" => JSCalendarProperty::Trigger, + "uid" => JSCalendarProperty::Uid, + "until" => JSCalendarProperty::Until, + "updated" => JSCalendarProperty::Updated, + "uri" => JSCalendarProperty::Uri, + "useDefaultAlerts" => JSCalendarProperty::UseDefaultAlerts, + "utcEnd" => JSCalendarProperty::UtcEnd, + "utcStart" => JSCalendarProperty::UtcStart, + "version" => JSCalendarProperty::Version, + "virtualLocations" => JSCalendarProperty::VirtualLocations, + "when" => JSCalendarProperty::When, + "endTimeZone" => JSCalendarProperty::EndTimeZone, + "mainLocationId" => JSCalendarProperty::MainLocationId, + "organizerCalendarAddress" => JSCalendarProperty::OrganizerCalendarAddress, + "recurrenceRule" => JSCalendarProperty::RecurrenceRule, + "properties" => JSCalendarProperty::Properties, + "components" => JSCalendarProperty::Components, + "valueType" => JSCalendarProperty::ValueType, + "convertedProperties" => JSCalendarProperty::ConvertedProperties, + "parameters" => JSCalendarProperty::Parameters, + "iCalendar" => JSCalendarProperty::ICalendar, + "blobId" => JSCalendarProperty::BlobId, + ) + .ok_or(()) + } +} + +impl JSCalendarProperty { + pub fn to_string(&self) -> Cow<'static, str> { + match self { + JSCalendarProperty::Type => "@type", + JSCalendarProperty::Acknowledged => "acknowledged", + JSCalendarProperty::Action => "action", + JSCalendarProperty::Alerts => "alerts", + JSCalendarProperty::BaseEventId => "baseEventId", + JSCalendarProperty::ByDay => "byDay", + JSCalendarProperty::ByHour => "byHour", + JSCalendarProperty::ByMinute => "byMinute", + JSCalendarProperty::ByMonth => "byMonth", + JSCalendarProperty::ByMonthDay => "byMonthDay", + JSCalendarProperty::BySecond => "bySecond", + JSCalendarProperty::BySetPosition => "bySetPosition", + JSCalendarProperty::ByWeekNo => "byWeekNo", + JSCalendarProperty::ByYearDay => "byYearDay", + JSCalendarProperty::CalendarAddress => "calendarAddress", + JSCalendarProperty::CalendarIds => "calendarIds", + JSCalendarProperty::Categories => "categories", + JSCalendarProperty::Color => "color", + JSCalendarProperty::ContentType => "contentType", + JSCalendarProperty::Coordinates => "coordinates", + JSCalendarProperty::Count => "count", + JSCalendarProperty::Created => "created", + JSCalendarProperty::Day => "day", + JSCalendarProperty::DelegatedFrom => "delegatedFrom", + JSCalendarProperty::DelegatedTo => "delegatedTo", + JSCalendarProperty::Description => "description", + JSCalendarProperty::DescriptionContentType => "descriptionContentType", + JSCalendarProperty::Display => "display", + JSCalendarProperty::Due => "due", + JSCalendarProperty::Duration => "duration", + JSCalendarProperty::Email => "email", + JSCalendarProperty::Entries => "entries", + JSCalendarProperty::EstimatedDuration => "estimatedDuration", + JSCalendarProperty::Excluded => "excluded", + JSCalendarProperty::ExpectReply => "expectReply", + JSCalendarProperty::Features => "features", + JSCalendarProperty::FirstDayOfWeek => "firstDayOfWeek", + JSCalendarProperty::FreeBusyStatus => "freeBusyStatus", + JSCalendarProperty::Frequency => "frequency", + JSCalendarProperty::HideAttendees => "hideAttendees", + JSCalendarProperty::Href => "href", + JSCalendarProperty::Id => "id", + JSCalendarProperty::Interval => "interval", + JSCalendarProperty::InvitedBy => "invitedBy", + JSCalendarProperty::IsDraft => "isDraft", + JSCalendarProperty::IsOrigin => "isOrigin", + JSCalendarProperty::Keywords => "keywords", + JSCalendarProperty::Kind => "kind", + JSCalendarProperty::Links => "links", + JSCalendarProperty::Locale => "locale", + JSCalendarProperty::Locations => "locations", + JSCalendarProperty::LocationTypes => "locationTypes", + JSCalendarProperty::MayInviteOthers => "mayInviteOthers", + JSCalendarProperty::MayInviteSelf => "mayInviteSelf", + JSCalendarProperty::MemberOf => "memberOf", + JSCalendarProperty::Method => "method", + JSCalendarProperty::Name => "name", + JSCalendarProperty::NthOfPeriod => "nthOfPeriod", + JSCalendarProperty::Offset => "offset", + JSCalendarProperty::Participants => "participants", + JSCalendarProperty::ParticipationComment => "participationComment", + JSCalendarProperty::ParticipationStatus => "participationStatus", + JSCalendarProperty::PercentComplete => "percentComplete", + JSCalendarProperty::Priority => "priority", + JSCalendarProperty::Privacy => "privacy", + JSCalendarProperty::ProdId => "prodId", + JSCalendarProperty::Progress => "progress", + JSCalendarProperty::RecurrenceId => "recurrenceId", + JSCalendarProperty::RecurrenceIdTimeZone => "recurrenceIdTimeZone", + JSCalendarProperty::RecurrenceOverrides => "recurrenceOverrides", + JSCalendarProperty::Rel => "rel", + JSCalendarProperty::RelatedTo => "relatedTo", + JSCalendarProperty::Relation => "relation", + JSCalendarProperty::RelativeTo => "relativeTo", + JSCalendarProperty::ReplyTo => "replyTo", + JSCalendarProperty::RequestStatus => "requestStatus", + JSCalendarProperty::Roles => "roles", + JSCalendarProperty::Rscale => "rscale", + JSCalendarProperty::SentBy => "sentBy", + JSCalendarProperty::ScheduleAgent => "scheduleAgent", + JSCalendarProperty::ScheduleForceSend => "scheduleForceSend", + JSCalendarProperty::ScheduleSequence => "scheduleSequence", + JSCalendarProperty::ScheduleStatus => "scheduleStatus", + JSCalendarProperty::ScheduleUpdated => "scheduleUpdated", + JSCalendarProperty::SendTo => "sendTo", + JSCalendarProperty::Sequence => "sequence", + JSCalendarProperty::ShowWithoutTime => "showWithoutTime", + JSCalendarProperty::Size => "size", + JSCalendarProperty::Skip => "skip", + JSCalendarProperty::Source => "source", + JSCalendarProperty::Start => "start", + JSCalendarProperty::Status => "status", + JSCalendarProperty::TimeZone => "timeZone", + JSCalendarProperty::Title => "title", + JSCalendarProperty::Trigger => "trigger", + JSCalendarProperty::Uid => "uid", + JSCalendarProperty::Until => "until", + JSCalendarProperty::Updated => "updated", + JSCalendarProperty::Uri => "uri", + JSCalendarProperty::UseDefaultAlerts => "useDefaultAlerts", + JSCalendarProperty::UtcEnd => "utcEnd", + JSCalendarProperty::UtcStart => "utcStart", + JSCalendarProperty::Version => "version", + JSCalendarProperty::VirtualLocations => "virtualLocations", + JSCalendarProperty::When => "when", + JSCalendarProperty::EndTimeZone => "endTimeZone", + JSCalendarProperty::MainLocationId => "mainLocationId", + JSCalendarProperty::OrganizerCalendarAddress => "organizerCalendarAddress", + JSCalendarProperty::RecurrenceRule => "recurrenceRule", + JSCalendarProperty::Properties => "properties", + JSCalendarProperty::Components => "components", + JSCalendarProperty::ValueType => "valueType", + JSCalendarProperty::ConvertedProperties => "convertedProperties", + JSCalendarProperty::Parameters => "parameters", + JSCalendarProperty::ICalendar => "iCalendar", + JSCalendarProperty::BlobId => "blobId", + JSCalendarProperty::LinkDisplay(v) => v.as_str(), + JSCalendarProperty::VirtualLocationFeature(v) => v.as_str(), + JSCalendarProperty::ParticipantRole(v) => v.as_str(), + JSCalendarProperty::RelationValue(v) => v.as_str(), + JSCalendarProperty::LinkRelation(v) => return v.as_str().to_string().into(), + JSCalendarProperty::DateTime(dt) => return dt.to_rfc3339().into(), + JSCalendarProperty::Pointer(pointer) => return Cow::Owned(pointer.to_string()), + JSCalendarProperty::IdValue(id) => return id.to_string().into(), + JSCalendarProperty::IdReference(s) => return format!("#{}", s).into(), + } + .into() + } + + pub(crate) fn is_forbidden_override_patch(&self) -> bool { + matches!( + self, + JSCalendarProperty::Type + | JSCalendarProperty::Method + | JSCalendarProperty::OrganizerCalendarAddress + | JSCalendarProperty::Privacy + | JSCalendarProperty::ProdId + | JSCalendarProperty::RecurrenceId + | JSCalendarProperty::RecurrenceIdTimeZone + | JSCalendarProperty::SentBy + | JSCalendarProperty::Uid + | JSCalendarProperty::RecurrenceOverrides + | JSCalendarProperty::RecurrenceRule + ) + } + + pub(crate) fn is_forbidden_override_pointer(pointer: &JsonPointer) -> bool { + let mut items = pointer + .iter() + .filter(|item| !matches!(item, JsonPointerItem::Root)); + + match (items.next(), items.next(), items.next(), items.next()) { + ( + Some(JsonPointerItem::Key(Key::Property( + JSCalendarProperty::RecurrenceOverrides | JSCalendarProperty::RecurrenceRule, + ))), + .., + ) => true, + (Some(JsonPointerItem::Key(Key::Property(property))), None, ..) => { + property.is_forbidden_override_patch() + } + ( + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::Participants))), + Some(_), + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::CalendarAddress))), + None, + ) => true, + _ => false, + } + } +} + +impl FromStr for JSCalendarType { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "Event" => JSCalendarType::Event, + "Task" => JSCalendarType::Task, + "Group" => JSCalendarType::Group, + "Alert" => JSCalendarType::Alert, + "Boolean" => JSCalendarType::Boolean, + "Duration" => JSCalendarType::Duration, + "Id" => JSCalendarType::Id, + "Int" => JSCalendarType::Int, + "LocalDateTime" => JSCalendarType::LocalDateTime, + "Link" => JSCalendarType::Link, + "Location" => JSCalendarType::Location, + "NDay" => JSCalendarType::NDay, + "Number" => JSCalendarType::Number, + "Participant" => JSCalendarType::Participant, + "PatchObject" => JSCalendarType::PatchObject, + "RecurrenceRule" => JSCalendarType::RecurrenceRule, + "Relation" => JSCalendarType::Relation, + "SignedDuration" => JSCalendarType::SignedDuration, + "String" => JSCalendarType::String, + "TimeZone" => JSCalendarType::TimeZone, + "TimeZoneId" => JSCalendarType::TimeZoneId, + "TimeZoneRule" => JSCalendarType::TimeZoneRule, + "UnsignedInt" => JSCalendarType::UnsignedInt, + "UTCDateTime" => JSCalendarType::UTCDateTime, + "VirtualLocation" => JSCalendarType::VirtualLocation, + "ICalComponent" => JSCalendarType::ICalComponent, + "AbsoluteTrigger" => JSCalendarType::AbsoluteTrigger, + "OffsetTrigger" => JSCalendarType::OffsetTrigger, + ) + .ok_or(()) + } +} + +impl JSCalendarType { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarType::Event => "Event", + JSCalendarType::Task => "Task", + JSCalendarType::Group => "Group", + JSCalendarType::Alert => "Alert", + JSCalendarType::Boolean => "Boolean", + JSCalendarType::Duration => "Duration", + JSCalendarType::Id => "Id", + JSCalendarType::Int => "Int", + JSCalendarType::LocalDateTime => "LocalDateTime", + JSCalendarType::Link => "Link", + JSCalendarType::Location => "Location", + JSCalendarType::NDay => "NDay", + JSCalendarType::Number => "Number", + JSCalendarType::Participant => "Participant", + JSCalendarType::PatchObject => "PatchObject", + JSCalendarType::RecurrenceRule => "RecurrenceRule", + JSCalendarType::Relation => "Relation", + JSCalendarType::SignedDuration => "SignedDuration", + JSCalendarType::String => "String", + JSCalendarType::TimeZone => "TimeZone", + JSCalendarType::TimeZoneId => "TimeZoneId", + JSCalendarType::TimeZoneRule => "TimeZoneRule", + JSCalendarType::UnsignedInt => "UnsignedInt", + JSCalendarType::UTCDateTime => "UTCDateTime", + JSCalendarType::VirtualLocation => "VirtualLocation", + JSCalendarType::ICalComponent => "ICalComponent", + JSCalendarType::OffsetTrigger => "OffsetTrigger", + JSCalendarType::AbsoluteTrigger => "AbsoluteTrigger", + } + } +} + +impl FromStr for JSCalendarAlertAction { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "display" => JSCalendarAlertAction::Display, + "email" => JSCalendarAlertAction::Email + ) + .ok_or(()) + } +} + +impl JSCalendarAlertAction { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarAlertAction::Display => "display", + JSCalendarAlertAction::Email => "email", + } + } +} + +impl FromStr for JSCalendarLinkDisplay { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "badge" => JSCalendarLinkDisplay::Badge, + "graphic" => JSCalendarLinkDisplay::Graphic, + "fullsize" => JSCalendarLinkDisplay::Fullsize, + "thumbnail" => JSCalendarLinkDisplay::Thumbnail + ) + .ok_or(()) + } +} + +impl JSCalendarLinkDisplay { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarLinkDisplay::Badge => "badge", + JSCalendarLinkDisplay::Graphic => "graphic", + JSCalendarLinkDisplay::Fullsize => "fullsize", + JSCalendarLinkDisplay::Thumbnail => "thumbnail", + } + } +} + +impl FromStr for JSCalendarVirtualLocationFeature { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "audio" => JSCalendarVirtualLocationFeature::Audio, + "chat" => JSCalendarVirtualLocationFeature::Chat, + "feed" => JSCalendarVirtualLocationFeature::Feed, + "moderator" => JSCalendarVirtualLocationFeature::Moderator, + "phone" => JSCalendarVirtualLocationFeature::Phone, + "screen" => JSCalendarVirtualLocationFeature::Screen, + "video" => JSCalendarVirtualLocationFeature::Video + ) + .ok_or(()) + } +} + +impl JSCalendarVirtualLocationFeature { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarVirtualLocationFeature::Audio => "audio", + JSCalendarVirtualLocationFeature::Chat => "chat", + JSCalendarVirtualLocationFeature::Feed => "feed", + JSCalendarVirtualLocationFeature::Moderator => "moderator", + JSCalendarVirtualLocationFeature::Phone => "phone", + JSCalendarVirtualLocationFeature::Screen => "screen", + JSCalendarVirtualLocationFeature::Video => "video", + } + } +} + +impl FromStr for JSCalendarFreeBusyStatus { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "free" => JSCalendarFreeBusyStatus::Free, + "busy" => JSCalendarFreeBusyStatus::Busy + ) + .ok_or(()) + } +} + +impl JSCalendarFreeBusyStatus { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarFreeBusyStatus::Free => "free", + JSCalendarFreeBusyStatus::Busy => "busy", + } + } +} + +impl FromStr for JSCalendarParticipantKind { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "individual" => JSCalendarParticipantKind::Individual, + "group" => JSCalendarParticipantKind::Group, + "resource" => JSCalendarParticipantKind::Resource, + "location" => JSCalendarParticipantKind::Location + ) + .ok_or(()) + } +} + +impl JSCalendarParticipantKind { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarParticipantKind::Individual => "individual", + JSCalendarParticipantKind::Group => "group", + JSCalendarParticipantKind::Resource => "resource", + JSCalendarParticipantKind::Location => "location", + } + } +} + +impl FromStr for JSCalendarParticipationStatus { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "needs-action" => JSCalendarParticipationStatus::NeedsAction, + "accepted" => JSCalendarParticipationStatus::Accepted, + "declined" => JSCalendarParticipationStatus::Declined, + "tentative" => JSCalendarParticipationStatus::Tentative, + "delegated" => JSCalendarParticipationStatus::Delegated + ) + .ok_or(()) + } +} + +impl JSCalendarParticipationStatus { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarParticipationStatus::NeedsAction => "needs-action", + JSCalendarParticipationStatus::Accepted => "accepted", + JSCalendarParticipationStatus::Declined => "declined", + JSCalendarParticipationStatus::Tentative => "tentative", + JSCalendarParticipationStatus::Delegated => "delegated", + } + } +} + +impl FromStr for JSCalendarPrivacy { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "public" => JSCalendarPrivacy::Public, + "private" => JSCalendarPrivacy::Private, + "secret" => JSCalendarPrivacy::Secret + ) + .ok_or(()) + } +} + +impl JSCalendarPrivacy { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarPrivacy::Public => "public", + JSCalendarPrivacy::Private => "private", + JSCalendarPrivacy::Secret => "secret", + } + } +} + +impl FromStr for JSCalendarProgress { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "needs-action" => JSCalendarProgress::NeedsAction, + "in-process" => JSCalendarProgress::InProcess, + "completed" => JSCalendarProgress::Completed, + "failed" => JSCalendarProgress::Failed, + "cancelled" => JSCalendarProgress::Cancelled, + ) + .ok_or(()) + } +} + +impl JSCalendarProgress { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarProgress::NeedsAction => "needs-action", + JSCalendarProgress::InProcess => "in-process", + JSCalendarProgress::Completed => "completed", + JSCalendarProgress::Failed => "failed", + JSCalendarProgress::Cancelled => "cancelled", + } + } +} + +impl FromStr for JSCalendarRelation { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "first" => JSCalendarRelation::First, + "next" => JSCalendarRelation::Next, + "child" => JSCalendarRelation::Child, + "parent" => JSCalendarRelation::Parent, + "snooze" => JSCalendarRelation::Snooze, + ) + .ok_or(()) + } +} + +impl JSCalendarRelation { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarRelation::First => "first", + JSCalendarRelation::Next => "next", + JSCalendarRelation::Child => "child", + JSCalendarRelation::Parent => "parent", + JSCalendarRelation::Snooze => "snooze", + } + } +} + +impl FromStr for JSCalendarRelativeTo { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "start" => JSCalendarRelativeTo::Start, + "end" => JSCalendarRelativeTo::End + ) + .ok_or(()) + } +} + +impl JSCalendarRelativeTo { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarRelativeTo::Start => "start", + JSCalendarRelativeTo::End => "end", + } + } +} + +impl FromStr for JSCalendarParticipantRole { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "owner" => JSCalendarParticipantRole::Owner, + "optional" => JSCalendarParticipantRole::Optional, + "informational" => JSCalendarParticipantRole::Informational, + "chair" => JSCalendarParticipantRole::Chair, + "required" => JSCalendarParticipantRole::Required, + ) + .ok_or(()) + } +} + +impl JSCalendarParticipantRole { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarParticipantRole::Owner => "owner", + JSCalendarParticipantRole::Optional => "optional", + JSCalendarParticipantRole::Informational => "informational", + JSCalendarParticipantRole::Chair => "chair", + JSCalendarParticipantRole::Required => "required", + } + } +} + +impl FromStr for JSCalendarScheduleAgent { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "server" => JSCalendarScheduleAgent::Server, + "client" => JSCalendarScheduleAgent::Client, + "none" => JSCalendarScheduleAgent::None + ) + .ok_or(()) + } +} + +impl JSCalendarScheduleAgent { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarScheduleAgent::Server => "server", + JSCalendarScheduleAgent::Client => "client", + JSCalendarScheduleAgent::None => "none", + } + } +} + +impl FromStr for JSCalendarEventStatus { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "confirmed" => JSCalendarEventStatus::Confirmed, + "cancelled" => JSCalendarEventStatus::Cancelled, + "tentative" => JSCalendarEventStatus::Tentative + ) + .ok_or(()) + } +} + +impl JSCalendarEventStatus { + pub fn as_str(&self) -> &'static str { + match self { + JSCalendarEventStatus::Confirmed => "confirmed", + JSCalendarEventStatus::Cancelled => "cancelled", + JSCalendarEventStatus::Tentative => "tentative", + } + } +} + +impl ICalendarFrequency { + pub fn as_js_str(&self) -> &'static str { + match self { + ICalendarFrequency::Secondly => "secondly", + ICalendarFrequency::Minutely => "minutely", + ICalendarFrequency::Hourly => "hourly", + ICalendarFrequency::Daily => "daily", + ICalendarFrequency::Weekly => "weekly", + ICalendarFrequency::Monthly => "monthly", + ICalendarFrequency::Yearly => "yearly", + } + } +} + +impl ICalendarSkip { + pub fn as_js_str(&self) -> &'static str { + match self { + ICalendarSkip::Omit => "omit", + ICalendarSkip::Backward => "backward", + ICalendarSkip::Forward => "forward", + } + } +} + +impl ICalendarWeekday { + pub fn as_js_str(&self) -> &'static str { + match self { + ICalendarWeekday::Sunday => "su", + ICalendarWeekday::Monday => "mo", + ICalendarWeekday::Tuesday => "tu", + ICalendarWeekday::Wednesday => "we", + ICalendarWeekday::Thursday => "th", + ICalendarWeekday::Friday => "fr", + ICalendarWeekday::Saturday => "sa", + } + } +} + +impl ICalendarMethod { + pub fn as_js_str(&self) -> &'static str { + match self { + ICalendarMethod::Publish => "publish", + ICalendarMethod::Request => "request", + ICalendarMethod::Reply => "reply", + ICalendarMethod::Add => "add", + ICalendarMethod::Cancel => "cancel", + ICalendarMethod::Refresh => "refresh", + ICalendarMethod::Counter => "counter", + ICalendarMethod::Declinecounter => "declinecounter", + } + } +} diff --git a/vendor/calcard/src/jscontact/export/convert.rs b/vendor/calcard/src/jscontact/export/convert.rs new file mode 100644 index 00000000..bb319796 --- /dev/null +++ b/vendor/calcard/src/jscontact/export/convert.rs @@ -0,0 +1,1959 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{Data, IanaParse, IanaType, timezone::Tz}, + jscontact::{ + JSContact, JSContactId, JSContactKind, JSContactProperty, JSContactValue, + export::{ + State, + props::{ + build_path, convert_anniversary, convert_types, convert_value, find_text_param, + map_kind, + }, + }, + }, + vcard::{ + Jscomp, VCard, VCardEntry, VCardLevel, VCardParameter, VCardParameterValue, VCardPhonetic, + VCardProperty, VCardType, VCardValue, VCardValueType, ValueType, + }, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value}; +use std::{collections::HashMap, str::FromStr}; + +impl<'x, I, B> JSContact<'x, I, B> +where + I: JSContactId, + B: JSContactId, +{ + pub fn into_vcard(self) -> Option { + let mut state = State::::default(); + let mut properties = self.0.into_object()?.into_vec(); + let mut localized_properties: HashMap> = HashMap::new(); + + for (property, value) in &mut properties { + match (property, value) { + (Key::Property(JSContactProperty::Localizations), Value::Object(obj)) => { + for (lang, value) in std::mem::take(obj.as_mut_vec()) { + let mut localizations: Value< + '_, + JSContactProperty, + JSContactValue, + > = Value::Object(Map::from(Vec::with_capacity(3))); + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(JSContactProperty::Pointer(ptr)) => ptr, + _ => JsonPointer::parse(key.to_string().as_ref()), + }; + + let ptr_path = ptr.to_string(); + if let Some(value) = build_path( + &mut localizations, + ptr.into_inner().into_iter().peekable(), + value, + ) { + state.insert_jsprop( + &[ + JSContactProperty::Localizations::.to_string().as_ref(), + lang.to_string().as_ref(), + ptr_path.as_str(), + ], + value, + ); + } + } + if let Some(localizations) = + localizations.into_object().filter(|x| !x.is_empty()) + { + localized_properties + .insert(lang.into_string(), localizations.into_vec()); + } + } + } + (Key::Property(JSContactProperty::VCard), Value::Object(obj)) => { + for (sub_property, value) in std::mem::take(obj.as_mut_vec()) { + match sub_property { + Key::Property(JSContactProperty::ConvertedProperties) => { + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(JSContactProperty::Pointer(ptr)) => ptr, + _ => JsonPointer::parse(key.to_string().as_ref()), + }; + + let mut keys = Vec::with_capacity(2); + for item in ptr.into_iter() { + match item { + JsonPointerItem::Key(key) => { + let key = match &key { + Key::Borrowed(v) if v.contains('/') => v, + Key::Owned(v) if v.contains('/') => v.as_str(), + _ => { + keys.push(key); + continue; + } + }; + for item in JsonPointer::parse(key).into_iter() { + keys.push(match item { + JsonPointerItem::Key(k) => k, + JsonPointerItem::Number(n) => { + Key::Owned(n.to_string()) + } + JsonPointerItem::Root + | JsonPointerItem::Wildcard => continue, + }); + } + } + JsonPointerItem::Number(v) => { + keys.push(Key::Owned(v.to_string())); + } + JsonPointerItem::Root | JsonPointerItem::Wildcard => (), + } + } + + state.converted_props.push((keys, value)); + } + } + Key::Property(JSContactProperty::Properties) => { + if let Some(value) = value.into_array() { + state.import_properties(value); + } + } + _ => { + state.insert_jsprop( + &[ + JSContactProperty::VCard::.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + if !state.converted_props.is_empty() { + state.converted_props.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + } + } + _ => {} + } + } + + // Resolve organizational mappings + let mut org_mappings: HashMap = HashMap::new(); + for (keys, value) in &state.converted_props { + match (keys.first(), keys.get(2)) { + ( + Some(Key::Property(JSContactProperty::Titles)), + Some(Key::Property(JSContactProperty::Name)), + ) => { + if let Some(group) = find_text_param(value, "group") { + org_mappings.entry(group.into_owned()).or_default().0 = + keys[1].to_string().into_owned(); + } + } + (Some(Key::Property(JSContactProperty::Organizations)), _) => { + if let Some(group) = find_text_param(value, "group") { + org_mappings.entry(group.into_owned()).or_default().1 = true; + } + } + _ => {} + } + } + + // Localization maps + #[cfg(test)] + let mut has_fn = crate::jscontact::fn_derivation_disabled(); + #[cfg(not(test))] + let mut has_fn = false; + + let has_localizations = !localized_properties.is_empty(); + let mut name_pos_map: [Option; 20] = [None; 20]; + let mut adr_pos_map: HashMap; 20]> = HashMap::new(); + let mut places_map: HashMap)> = + HashMap::new(); + + for (properties, language) in [(properties, None)].into_iter().chain( + localized_properties + .into_iter() + .map(|(lang, properties)| (properties, Some(lang))), + ) { + let is_localized = language.is_some(); + state.language = language; + + for (property, value) in properties { + let Key::Property(property) = property else { + state.insert_jsprop(&[property.to_string().as_ref()], value); + continue; + }; + + match property { + JSContactProperty::Uid + | JSContactProperty::Kind + | JSContactProperty::Language + | JSContactProperty::ProdId + | JSContactProperty::Created + | JSContactProperty::Updated => { + match convert_value(value, &ValueType::Vcard(VCardValueType::Text)) { + Ok(value) => { + let vcard_property = match property { + JSContactProperty::Uid => VCardProperty::Uid, + JSContactProperty::Kind => VCardProperty::Kind, + JSContactProperty::Language => VCardProperty::Language, + JSContactProperty::ProdId => VCardProperty::Prodid, + JSContactProperty::Created => VCardProperty::Created, + JSContactProperty::Updated => VCardProperty::Rev, + _ => unreachable!(), + }; + state.insert_vcard( + &[property], + VCardEntry::new(vcard_property).with_value(value), + ); + } + Err(value) => { + state.insert_jsprop(&[property.to_string().as_ref()], value); + } + } + } + JSContactProperty::Directories => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::OrgDirectory); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Kind) => { + if matches!( + value, + Value::Element(JSContactValue::Kind( + JSContactKind::Entry + )) + ) { + entry.name = VCardProperty::Source; + } + } + Key::Property(JSContactProperty::Uri) => { + if let Some(text) = value.into_string() { + entry.values.push(VCardValue::Text(text.into_owned())); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_u64() { + entry.params.push(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::ListAs) => { + if let Some(index) = value.as_u64() { + entry.params.push(VCardParameter::index(index as u32)); + } + } + Key::Property(JSContactProperty::MediaType) => { + if let Some(text) = value.into_string() { + entry + .params + .push(VCardParameter::mediatype(text.into_owned())); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(text) = value.into_string() { + entry.values.push(VCardValue::Text(text.into_owned())); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Directories], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Anniversaries => { + for (name, value) in value.into_expanded_object() { + if let Some((vcard_property, mut vcard_place_property)) = map_kind( + &value, + [ + ( + JSContactKind::Birth, + (VCardProperty::Bday, Some(VCardProperty::Birthplace)), + ), + ( + JSContactKind::Death, + (VCardProperty::Deathdate, Some(VCardProperty::Deathplace)), + ), + (JSContactKind::Wedding, (VCardProperty::Anniversary, None)), + ], + ) + .or_else(|| { + if is_localized { + places_map.get(name.to_string().as_ref()).cloned() + } else { + None + } + }) { + let mut date = None; + let mut place = None; + let mut calscale = None; + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Kind) => {} + Key::Property(JSContactProperty::Date) => { + if let Ok((date_, calscale_)) = + convert_anniversary(value) + { + date = Some(date_); + calscale = calscale_; + } + } + Key::Property(JSContactProperty::Place) + if vcard_place_property.is_some() + && value.is_object_and_contains_any_key(&[ + Key::Property(JSContactProperty::Full), + Key::Property(JSContactProperty::Coordinates), + ]) => + { + if has_localizations && !is_localized { + places_map + .entry(name.to_string().into_owned()) + .or_insert_with(|| { + ( + vcard_property.clone(), + vcard_place_property.clone(), + ) + }); + } + + for (place_property, value) in + value.into_expanded_object() + { + match place_property { + Key::Property(JSContactProperty::Full) + if vcard_place_property.is_some() => + { + if let Some(text) = value.into_string() { + place = Some( + VCardEntry::new( + vcard_place_property + .take() + .unwrap(), + ) + .with_value(text.into_owned()), + ); + } + } + Key::Property( + JSContactProperty::Coordinates, + ) if vcard_place_property.is_some() => { + if let Some(text) = value.into_string() { + place = Some( + VCardEntry::new( + vcard_place_property + .take() + .unwrap(), + ) + .with_value(text.into_owned()) + .with_param(VCardParameter::value( + VCardValueType::Uri, + )), + ); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + place_property.to_string().as_ref(), + ], + value, + ); + } + } + } + } + _ => { + state.insert_jsprop( + &[ + JSContactProperty::Anniversaries:: + .to_string() + .as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + // Add place if it exists + if let Some(place) = place { + state.insert_vcard( + &[ + JSContactProperty::Anniversaries, + JSContactProperty::Place, + ], + place.with_param(VCardParameter::prop_id( + name.to_string().into_owned(), + )), + ); + } + + // Add date + if let Some(date) = date { + let mut entry = VCardEntry::new(vcard_property); + if let Some(calscale) = calscale { + entry.params.push(VCardParameter::calscale(calscale)); + } + state.insert_vcard( + &[ + JSContactProperty::Anniversaries, + JSContactProperty::Date, + ], + entry + .with_param(VCardParameter::prop_id(name.into_string())) + .with_value(VCardValue::PartialDateTime(date)), + ); + } + } else { + state.insert_jsprop( + &[property.to_string().as_ref(), name.to_string().as_ref()], + value, + ); + } + } + } + JSContactProperty::Name => { + let mut params = Vec::new(); + let mut parts: Vec>> = vec![None; 7]; + let mut jscomps = vec![Jscomp::Separator(String::new())]; + let mut num_parts = 0; + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Components) => { + for (index, components) in value + .into_array() + .unwrap_or_default() + .into_iter() + .enumerate() + { + let mut comp_value = None; + let mut comp_phonetic = None; + let mut comp_pos = None; + let mut is_separator = false; + + for (component, value) in components.into_expanded_object() + { + match (component, value) { + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind( + JSContactKind::Separator, + )), + ) => { + is_separator = true; + } + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ) => { + comp_pos = kind.to_vcard_n_pos(); + } + ( + Key::Property(JSContactProperty::Value), + value, + ) => { + comp_value = value.into_string(); + } + ( + Key::Property(JSContactProperty::Phonetic), + value, + ) if is_localized => { + comp_phonetic = value.into_string(); + } + (component, value) => { + state.insert_jsprop( + &[ + JSContactProperty::Name:: + .to_string() + .as_ref(), + JSContactProperty::Components:: + .to_string() + .as_ref(), + index.to_string().as_str(), + component.to_string().as_ref(), + ], + value, + ); + } + } + } + + if has_localizations + && !is_localized + && let Some(value) = name_pos_map.get_mut(index) + { + *value = comp_pos; + } + + if let Some(comp_value) = comp_value.or(comp_phonetic) { + if let Some(comp_pos) = comp_pos.or_else(|| { + if is_localized && !is_separator { + name_pos_map + .get(index) + .copied() + .unwrap_or_default() + } else { + None + } + }) { + let part = &mut parts[comp_pos]; + if let Some(part) = part { + let value = part.len() as u32; + part.push(comp_value.into_owned()); + jscomps.push(Jscomp::Entry { + position: comp_pos as u32, + value, + }); + } else { + *part = Some(vec![comp_value.into_owned()]); + jscomps.push(Jscomp::Entry { + position: comp_pos as u32, + value: 0, + }); + } + num_parts = num_parts.max(comp_pos + 1); + } else if is_separator { + jscomps.push(Jscomp::Separator( + comp_value.into_owned(), + )); + } + } + } + } + Key::Property(JSContactProperty::Full) => { + if let Some(text) = value.into_string() { + has_fn = true; + state.insert_vcard( + &[JSContactProperty::Name, JSContactProperty::Full], + VCardEntry::new(VCardProperty::Fn) + .with_value(text.into_owned()), + ); + } + } + Key::Property(JSContactProperty::SortAs) => { + let mut surname = None; + let mut given = None; + + for (key, value) in value.into_expanded_object() { + match key { + Key::Property(JSContactProperty::SortAsKind( + JSContactKind::Surname, + )) => { + surname = value.into_string(); + } + Key::Property(JSContactProperty::SortAsKind( + JSContactKind::Given, + )) => { + given = value.into_string(); + } + _ => { + state.insert_jsprop( + &[ + JSContactProperty::Name:: + .to_string() + .as_ref(), + JSContactProperty::SortAs:: + .to_string() + .as_ref(), + key.to_string().as_ref(), + ], + value, + ); + } + } + } + + if let (Some(surname), Some(given)) = + (surname.as_ref(), given.as_ref()) + { + params.push(VCardParameter::sort_as(format!( + "{surname},{given}" + ))); + } else if let Some(surname) = surname.or(given) { + params.push(VCardParameter::sort_as(surname.into_owned())); + } + } + Key::Property(JSContactProperty::PhoneticSystem) => { + if let Ok(phonetic_system) = + IanaType::::try_from(value) + { + params.push(VCardParameter::phonetic(phonetic_system)); + } + } + Key::Property(JSContactProperty::PhoneticScript) => { + if let Some(phonetic_script) = value.into_string() { + params.push(VCardParameter::script( + phonetic_script.into_owned(), + )); + } + } + Key::Property(JSContactProperty::DefaultSeparator) => { + if let Some(separator) = value.into_string() { + jscomps[0] = Jscomp::Separator(separator.into_owned()); + } + } + Key::Property(JSContactProperty::IsOrdered) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + if !has_fn { + let default_separator = match jscomps.first() { + Some(Jscomp::Separator(sep)) if !sep.is_empty() => sep.as_str(), + _ => " ", + }; + let mut full = String::new(); + let mut prev_value = false; + for jscomp in jscomps.iter().skip(1) { + match jscomp { + Jscomp::Entry { position, value } => { + if let Some(component) = parts + .get(*position as usize) + .and_then(|part| part.as_ref()) + .and_then(|part| part.get(*value as usize)) + { + if prev_value { + full.push_str(default_separator); + } + full.push_str(component); + prev_value = true; + } + } + Jscomp::Separator(sep) => { + full.push_str(sep); + prev_value = false; + } + } + } + + if !full.is_empty() { + state.insert_vcard( + &[JSContactProperty::Name, JSContactProperty::Full], + VCardEntry::new(VCardProperty::Fn) + .with_param(VCardParameter::derived(true)) + .with_value(full), + ); + has_fn = true; + } + } + + if num_parts > 0 || !params.is_empty() { + if jscomps.len() > 1 { + params.push(VCardParameter::jscomps(jscomps)); + } + + // To vCard: add any "surname2" NameComponent to the Family name component, after all "surname" values. + // To vCard: add any "generation" NameComponent to the Honorific suffix component. + for (from_pos, to_pos) in [(5, 0), (6, 4)] { + if let Some(v) = &parts[from_pos] { + let v = v.clone(); + if let Some(to) = &mut parts[to_pos] { + to.extend(v); + } else { + parts[to_pos] = Some(v); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Name, JSContactProperty::Components], + VCardEntry::new(VCardProperty::N) + .with_params(params) + .with_values( + parts + .into_iter() + .map(|v| { + if let Some(v) = v { + if v.len() > 1 { + VCardValue::Component(v) + } else { + VCardValue::Text( + v.into_iter().next().unwrap(), + ) + } + } else { + VCardValue::Text(Default::default()) + } + }) + .collect(), + ), + ); + } + } + JSContactProperty::SpeakToAs => { + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::GrammaticalGender) => { + if let Ok(gender) = convert_value(value, &ValueType::GramGender) + { + state.insert_vcard( + &[ + JSContactProperty::SpeakToAs, + JSContactProperty::GrammaticalGender, + ], + VCardEntry::new(VCardProperty::Gramgender) + .with_value(gender), + ); + } + } + Key::Property(JSContactProperty::Pronouns) => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Pronouns); + + for (pronoun_property, value) in + value.into_expanded_object() + { + match pronoun_property { + Key::Property(JSContactProperty::Pronouns) => { + if let Some(value) = value + .into_owned_string() + .map(VCardValue::Text) + { + entry.values = vec![value]; + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref( + pref as u32, + )); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + sub_property.to_string().as_ref(), + name.to_string().as_ref(), + pronoun_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[ + JSContactProperty::SpeakToAs, + JSContactProperty::Pronouns, + ], + entry.with_param(VCardParameter::prop_id( + name.into_string(), + )), + ); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + } + JSContactProperty::Nicknames => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Nickname); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Name) => { + if let Some(name) = + value.into_owned_string().map(VCardValue::Text) + { + entry.values = vec![name]; + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Nicknames], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Media => { + for (name, value) in value.into_expanded_object() { + if let Some(entry_type) = map_kind( + &value, + [ + (JSContactKind::Photo, VCardProperty::Photo), + (JSContactKind::Logo, VCardProperty::Logo), + (JSContactKind::Sound, VCardProperty::Sound), + ], + ) { + let mut entry = VCardEntry::new(entry_type); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Uri) => { + if let Some(uri) = value.into_string() { + let uri = match Data::try_parse(uri.as_bytes()) { + Some(data) => VCardValue::Binary(data), + None => VCardValue::Text(uri.into_owned()), + }; + + entry.values = vec![uri]; + } + } + Key::Property(JSContactProperty::MediaType) => { + if let Some(text) = value.into_string() { + entry.params.push(VCardParameter::mediatype( + text.into_owned(), + )); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(text) = value.into_string() { + entry + .params + .push(VCardParameter::label(text.into_owned())); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Kind) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Media], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } else { + state.insert_jsprop( + &[property.to_string().as_ref(), name.to_string().as_ref()], + value, + ); + } + } + } + JSContactProperty::Addresses => { + for (name, value) in value.into_expanded_object() { + let mut params = vec![]; + let mut parts: Vec>> = vec![None; 18]; + let mut jscomps = vec![Jscomp::Separator(String::new())]; + let mut num_parts = 0; + let mut timezone = None; + let mut geo = None; + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Components) => { + for (index, components) in value + .into_array() + .unwrap_or_default() + .into_iter() + .enumerate() + { + let mut comp_value = None; + let mut comp_phonetic = None; + let mut comp_pos = None; + let mut is_separator = false; + + for (key, value) in components.into_expanded_object() { + match (key, value) { + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind( + JSContactKind::Separator, + )), + ) => { + is_separator = true; + } + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ) => { + comp_pos = kind.to_vcard_adr_pos(); + } + ( + Key::Property(JSContactProperty::Value), + value, + ) => { + comp_value = value.into_string(); + } + ( + Key::Property(JSContactProperty::Phonetic), + value, + ) if is_localized => { + comp_phonetic = value.into_string(); + } + (key, value) => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + index.to_string().as_str(), + key.to_string().as_ref(), + ], + value, + ); + } + } + } + + if has_localizations + && !is_localized + && let Some(value) = adr_pos_map + .entry(name.to_string().into_owned()) + .or_default() + .get_mut(index) + { + *value = comp_pos; + } + + if let Some(comp_value) = comp_value.or(comp_phonetic) { + if let Some(comp_pos) = comp_pos.or_else(|| { + if is_localized && !is_separator { + adr_pos_map + .get(name.to_string().as_ref()) + .and_then(|map| map.get(index).copied()) + .unwrap_or_default() + } else { + None + } + }) { + let part = &mut parts[comp_pos]; + if let Some(part) = part { + let value = part.len() as u32; + part.push(comp_value.into_owned()); + jscomps.push(Jscomp::Entry { + position: comp_pos as u32, + value, + }); + } else { + *part = Some(vec![comp_value.into_owned()]); + jscomps.push(Jscomp::Entry { + position: comp_pos as u32, + value: 0, + }); + } + num_parts = num_parts.max(comp_pos + 1); + } else if is_separator { + jscomps.push(Jscomp::Separator( + comp_value.into_owned(), + )); + } + } + } + } + Key::Property(JSContactProperty::Full) => { + if let Some(value) = value.into_string() { + params.push(VCardParameter::label(value.into_owned())); + } + } + Key::Property(JSContactProperty::Coordinates) => { + geo = value.into_string(); + } + Key::Property(JSContactProperty::TimeZone) => { + timezone = value.into_string(); + } + Key::Property(JSContactProperty::CountryCode) => { + if let Some(value) = value.into_string() { + params.push(VCardParameter::cc(value.into_owned())); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(value) = value.as_i64() { + params.push(VCardParameter::pref(value as u32)); + } + } + Key::Property(JSContactProperty::PhoneticSystem) => { + if let Ok(phonetic_system) = + IanaType::::try_from(value) + { + params.push(VCardParameter::phonetic(phonetic_system)); + } + } + Key::Property(JSContactProperty::PhoneticScript) => { + if let Some(value) = value.into_string() { + params.push(VCardParameter::script(value.into_owned())); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::DefaultSeparator) => { + if let Some(value) = value.into_string() { + jscomps[0] = Jscomp::Separator(value.into_owned()); + } + } + Key::Property(JSContactProperty::IsOrdered) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + if let Some(timezone) = timezone { + if state.converted_props.iter().any(|(prop, _)| { + prop.len() == 3 + && prop[0] == Key::Property(JSContactProperty::Addresses) + && prop[1] == name + && prop[2] == Key::Property(JSContactProperty::TimeZone) + }) { + state.insert_vcard( + &[ + JSContactProperty::Addresses, + JSContactProperty::TimeZone, + ], + VCardEntry::new(VCardProperty::Tz).with_value( + Tz::from_str(&timezone) + .map(|tz| { + VCardValue::PartialDateTime(tz.offset_parts()) + }) + .unwrap_or_else(|_| { + VCardValue::Text(timezone.into_owned()) + }), + ), + ); + } else { + params.push(VCardParameter::tz(timezone.into_owned())); + } + } + + if let Some(geo) = geo { + if state.converted_props.iter().any(|(prop, _)| { + prop.len() == 3 + && prop[0] == Key::Property(JSContactProperty::Addresses) + && prop[1] == name + && prop[2] == Key::Property(JSContactProperty::Coordinates) + }) { + state.insert_vcard( + &[ + JSContactProperty::Addresses, + JSContactProperty::Coordinates, + ], + VCardEntry::new(VCardProperty::Geo) + .with_value(geo.into_owned()), + ); + } else { + params.push(VCardParameter::geo(geo.into_owned())); + } + } + + if num_parts > 0 || !params.is_empty() { + params.push(VCardParameter::prop_id(name.to_string().to_string())); + + if jscomps.len() > 1 { + params.push(VCardParameter::jscomps(jscomps)); + } + + /* + + apartment To vCard: set the values of the following components: + room, floor, apartment, building + + name To vCard: set the values of the following components: + number, name, block, direction, landmark, subdistrict, district + + */ + + for (comp_target, source) in [ + (1, [7, 8, 9, 12].as_slice()), + (2, [10, 11, 13, 17, 16, 14, 15].as_slice()), + ] { + let mut target = vec![]; + + for comp_pos in source { + if let Some(v) = &parts[*comp_pos] { + target.extend(v.iter().cloned()); + } + } + + if !target.is_empty() { + parts[comp_target] = Some(target); + } + } + + state.insert_vcard( + &[JSContactProperty::Addresses, JSContactProperty::Components], + VCardEntry::new(VCardProperty::Adr) + .with_params(params) + .with_values( + parts + .into_iter() + .map(|v| { + if let Some(v) = v { + if v.len() > 1 { + VCardValue::Component(v) + } else { + VCardValue::Text( + v.into_iter().next().unwrap(), + ) + } + } else { + VCardValue::Text(Default::default()) + } + }) + .collect(), + ), + ); + } + } + } + JSContactProperty::Organizations => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Org); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Name) => { + if let Some(value) = + value.into_owned_string().map(VCardValue::Text) + { + if entry.values.is_empty() { + entry.values = vec![value]; + } else { + entry.values.insert(0, value); + } + } + } + Key::Property(JSContactProperty::Units) => { + for (index, item) in value + .into_array() + .unwrap_or_default() + .into_iter() + .enumerate() + { + for (key, value) in item.into_expanded_object() { + match key { + Key::Property(JSContactProperty::Name) => { + if let Some(value) = value + .into_owned_string() + .map(VCardValue::Text) + { + entry.values.push(value); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + index.to_string().as_ref(), + key.to_string().as_ref(), + ], + value, + ); + } + } + } + } + } + Key::Property(JSContactProperty::SortAs) => { + if let Some(sort_as) = value.into_string() { + entry.add_param(VCardParameter::sort_as( + sort_as.into_owned(), + )); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Organizations], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Members => { + let mut has_members = false; + for name in value.into_expanded_boolean_set() { + has_members = true; + state.insert_vcard( + &[JSContactProperty::Members], + VCardEntry::new(VCardProperty::Member) + .with_value(name.into_string()), + ); + } + + if !has_members { + state.insert_jsprop( + &[property.to_string().as_ref()], + Value::Object(Map::from(Vec::new())), + ); + } + } + JSContactProperty::Keywords => { + let mut keywords = Vec::new(); + for name in value.into_expanded_boolean_set() { + keywords.push(VCardValue::Text(name.into_string())); + } + + state.insert_vcard( + &[JSContactProperty::Keywords], + VCardEntry::new(VCardProperty::Categories).with_values(keywords), + ); + } + JSContactProperty::Titles => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new( + map_kind( + &value, + [ + (JSContactKind::Title, VCardProperty::Title), + (JSContactKind::Role, VCardProperty::Role), + ], + ) + .unwrap_or(VCardProperty::Title), + ); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Name) => { + if let Some(value) = + value.into_owned_string().map(VCardValue::Text) + { + entry.values = vec![value]; + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::OrganizationId) + if org_mappings.values().any(|(id, has_mapping)| { + *has_mapping && id == name.to_string().as_ref() + }) => {} + Key::Property(JSContactProperty::Kind) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Titles], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Emails => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Email); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Address) => { + if let Some(email) = value.into_string() { + entry.values = + vec![VCardValue::Text(email.into_owned())]; + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(label) = value.into_string() { + entry + .params + .push(VCardParameter::label(label.into_owned())); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Emails], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::OnlineServices => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Socialprofile); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Uri) => { + if let Some(service) = value.into_string() { + entry.values = + vec![VCardValue::Text(service.into_owned())]; + } + } + Key::Property(JSContactProperty::Service) => { + if let Some(service) = value.into_string() { + entry.add_param(VCardParameter::service_type( + service.into_owned(), + )); + } + } + Key::Property(JSContactProperty::User) => { + if let Some(username) = value.into_string() { + entry.add_param(VCardParameter::username( + username.into_owned(), + )); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(label) = value.into_string() { + entry.add_param(VCardParameter::label( + label.into_owned(), + )); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::OnlineServices], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Phones => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Tel); + let mut types = vec![]; + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Number) => { + if let Some(email) = value.into_string() { + entry.values = + vec![VCardValue::Text(email.into_owned())]; + } + } + Key::Property( + JSContactProperty::Contexts | JSContactProperty::Features, + ) => { + if let Some(types_) = convert_types( + value, + matches!( + sub_property, + Key::Property(JSContactProperty::Contexts) + ), + ) { + if types.is_empty() { + types = types_; + } else { + types.extend(types_); + } + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(label) = value.into_string() { + entry + .params + .push(VCardParameter::label(label.into_owned())); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + if !types.is_empty() { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + state.insert_vcard( + &[JSContactProperty::Phones], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::PreferredLanguages => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Lang); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Language) => { + if let Some(lang) = value.into_string() { + entry.values = + vec![VCardValue::Text(lang.into_owned())]; + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(label) = value.into_string() { + entry + .params + .push(VCardParameter::label(label.into_owned())); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::PreferredLanguages], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Calendars => { + for (name, value) in value.into_expanded_object() { + if let Some(entry_type) = map_kind( + &value, + [ + (JSContactKind::FreeBusy, VCardProperty::Fburl), + (JSContactKind::Calendar, VCardProperty::Caluri), + ], + ) { + let mut entry = VCardEntry::new(entry_type); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Uri) => { + if let Some(value) = + value.into_owned_string().map(VCardValue::Text) + { + entry.values = vec![value]; + } + } + Key::Property(JSContactProperty::MediaType) => { + if let Some(text) = value.into_string() { + entry.params.push(VCardParameter::mediatype( + text.into_owned(), + )); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(text) = value.into_string() { + entry + .params + .push(VCardParameter::label(text.into_owned())); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::Kind) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Calendars], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } else { + state.insert_jsprop( + &[property.to_string().as_ref(), name.to_string().as_ref()], + value, + ); + } + } + } + JSContactProperty::SchedulingAddresses + | JSContactProperty::CryptoKeys + | JSContactProperty::Links => { + let vcard_property = match property { + JSContactProperty::SchedulingAddresses => VCardProperty::Caladruri, + JSContactProperty::CryptoKeys => VCardProperty::Key, + JSContactProperty::Links => VCardProperty::Url, + _ => unreachable!(), + }; + + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(vcard_property.clone()); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Uri) => { + if let Some(value) = + value.into_owned_string().map(VCardValue::Text) + { + entry.values = vec![value]; + } + } + Key::Property(JSContactProperty::MediaType) => { + if let Some(text) = value.into_string() { + entry + .params + .push(VCardParameter::mediatype(text.into_owned())); + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(text) = value.into_string() { + entry + .params + .push(VCardParameter::label(text.into_owned())); + } + } + Key::Property(JSContactProperty::Pref) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::pref(pref as u32)); + } + } + Key::Property(JSContactProperty::Contexts) => { + if let Some(types) = convert_types(value, true) { + for typ in types { + entry.params.push(VCardParameter::typ(typ)); + } + } + } + Key::Property(JSContactProperty::Kind) => { + if matches!( + value, + Value::Element(JSContactValue::Kind( + JSContactKind::Contact + )) + ) { + entry.name = VCardProperty::ContactUri; + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + std::slice::from_ref(&property), + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::Notes => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Note); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Note) => { + if let Some(text) = value.into_string() { + entry.values = + vec![VCardValue::Text(text.into_owned())]; + } + } + Key::Property(JSContactProperty::Created) => { + if let Value::Element(JSContactValue::Timestamp(t)) = value + { + entry.params.push(VCardParameter::created( + VCardParameterValue::Timestamp(t), + )); + } + } + Key::Property(JSContactProperty::Author) => { + for (name, value) in value.into_expanded_object() { + match name { + Key::Property(JSContactProperty::Name) => { + if let Some(name) = value.into_string() { + entry.add_param( + VCardParameter::author_name( + name.into_owned(), + ), + ); + } + } + Key::Property(JSContactProperty::Uri) => { + if let Some(uri) = value.into_string() { + entry.add_param(VCardParameter::author( + uri.into_owned(), + )); + } + } + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + name.to_string().as_ref(), + ], + value, + ); + } + } + } + } + + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::Notes], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } + } + JSContactProperty::PersonalInfo => { + for (name, value) in value.into_expanded_object() { + if let Some(entry_type) = map_kind( + &value, + [ + (JSContactKind::Expertise, VCardProperty::Expertise), + (JSContactKind::Hobby, VCardProperty::Hobby), + (JSContactKind::Interest, VCardProperty::Interest), + ], + ) { + let mut entry = VCardEntry::new(entry_type); + + for (sub_property, value) in value.into_expanded_object() { + match sub_property { + Key::Property(JSContactProperty::Value) => { + if let Some(value) = + value.into_owned_string().map(VCardValue::Text) + { + entry.values = vec![value]; + } + } + Key::Property(JSContactProperty::Label) => { + if let Some(label) = value.into_string() { + entry.add_param(VCardParameter::label( + label.into_owned(), + )); + } + } + Key::Property(JSContactProperty::ListAs) => { + if let Some(pref) = value.as_i64() { + entry.add_param(VCardParameter::index(pref as u32)); + } + } + Key::Property(JSContactProperty::Level) => { + if let Ok(level) = + IanaType::::try_from(value) + { + let level = if matches!( + entry.name, + VCardProperty::Expertise + ) { + match level { + IanaType::Iana(VCardLevel::High) => { + IanaType::Iana(VCardLevel::Expert) + } + IanaType::Iana(VCardLevel::Medium) => { + IanaType::Iana(VCardLevel::Average) + } + IanaType::Iana(VCardLevel::Low) => { + IanaType::Iana(VCardLevel::Beginner) + } + _ => level, + } + } else { + level + }; + entry.params.push(VCardParameter::level(level)); + } + } + Key::Property(JSContactProperty::Kind) => {} + _ => { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + } + + state.insert_vcard( + &[JSContactProperty::PersonalInfo], + entry.with_param(VCardParameter::prop_id(name.into_string())), + ); + } else { + state.insert_jsprop( + &[property.to_string().as_ref(), name.to_string().as_ref()], + value, + ); + } + } + } + JSContactProperty::RelatedTo => { + for (name, value) in value.into_expanded_object() { + let mut entry = VCardEntry::new(VCardProperty::Related) + .with_value(name.to_string().into_owned()); + + for (sub_property, value) in value.into_expanded_object() { + if let Key::Property(JSContactProperty::Relation) = sub_property { + for typ in value.into_expanded_boolean_set() { + let typ = typ.to_string(); + match VCardType::parse(typ.as_ref().as_bytes()) { + Some(typ) => { + entry.params.push(VCardParameter::typ(typ)) + } + None => { + entry + .params + .push(VCardParameter::typ(typ.into_owned())); + } + } + } + } else { + state.insert_jsprop( + &[ + property.to_string().as_ref(), + name.to_string().as_ref(), + sub_property.to_string().as_ref(), + ], + value, + ); + } + } + + state.insert_vcard(&[JSContactProperty::RelatedTo], entry); + } + } + JSContactProperty::Type + | JSContactProperty::Version + | JSContactProperty::Localizations + | JSContactProperty::VCard => (), + _ => { + if !matches!(value, Value::Null) { + state.insert_jsprop(&[property.to_string().as_ref()], value); + } + } + } + } + } + + if !has_fn { + state.insert_vcard( + &[JSContactProperty::Name, JSContactProperty::Full], + VCardEntry::new(VCardProperty::Fn) + .with_param(VCardParameter::derived(true)) + .with_value(String::new()), + ); + } + + Some(state.into_vcard()) + } + + pub fn into_inner(self) -> Value<'x, JSContactProperty, JSContactValue> { + self.0 + } +} diff --git a/vendor/calcard/src/jscontact/export/entry.rs b/vendor/calcard/src/jscontact/export/entry.rs new file mode 100644 index 00000000..beae0a1b --- /dev/null +++ b/vendor/calcard/src/jscontact/export/entry.rs @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{ + CalendarScale, IanaParse, + parser::{Boolean, Timestamp}, + }, + jscontact::{JSContactId, JSContactProperty, JSContactValue, export::params::ParamValue}, + vcard::{ + VCardEntry, VCardLevel, VCardParameter, VCardParameterName, VCardParameterValue, + VCardPhonetic, VCardProperty, VCardType, VCardValueType, + }, +}; +use jmap_tools::{Key, Value}; + +impl VCardEntry { + pub(super) fn import_converted_properties( + &mut self, + props: Value<'_, JSContactProperty, JSContactValue>, + ) where + I: JSContactId, + B: JSContactId, + { + for (key, value) in props.into_expanded_object() { + match key { + Key::Property(JSContactProperty::Name) => { + if let Some(name) = value.into_string() { + self.name = VCardProperty::parse(name.as_bytes()) + .unwrap_or(VCardProperty::Other(name.to_ascii_uppercase())); + } + } + Key::Property(JSContactProperty::Parameters) => { + self.import_jcard_params(value); + } + _ => {} + } + } + } + + pub(super) fn import_jcard_params( + &mut self, + params: Value<'_, JSContactProperty, JSContactValue>, + ) where + I: JSContactId, + B: JSContactId, + { + for (key, value) in params.into_expanded_object() { + let mut values = match value { + Value::Array(values) => values.into_iter().filter_map(ParamValue::try_from_value), + value => vec![value] + .into_iter() + .filter_map(ParamValue::try_from_value), + } + .peekable(); + + if values.peek().is_none() { + continue; + } + + let key = key.to_string(); + let Some(param) = VCardParameterName::try_parse(key.as_bytes()) else { + let key = key.into_owned(); + + for value in values { + self.params.push(VCardParameter { + name: VCardParameterName::Other(key.to_ascii_uppercase()), + value: value.into_string().into_owned().into(), + }); + } + + continue; + }; + + for value in values { + let value = match ¶m { + VCardParameterName::Value => { + let value = value.into_string(); + VCardValueType::parse(value.as_bytes()) + .map(VCardParameterValue::ValueType) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Pref | VCardParameterName::Index => { + match value.into_number() { + Ok(n) => VCardParameterValue::Integer(n as u32), + Err(value) => { + VCardParameterValue::Text(value.into_string().into_owned()) + } + } + } + VCardParameterName::Calscale => { + let value = value.into_string(); + CalendarScale::parse(value.as_bytes()) + .map(VCardParameterValue::Calscale) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Phonetic => { + let value = value.into_string(); + VCardPhonetic::parse(value.as_bytes()) + .map(VCardParameterValue::Phonetic) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Level => { + let value = value.into_string(); + VCardLevel::parse(value.as_bytes()) + .map(VCardParameterValue::Level) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Created => { + let value = value.into_string(); + Timestamp::parse(value.as_bytes()) + .map(|v| VCardParameterValue::Timestamp(v.0)) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Derived => { + let value = value.into_string(); + Boolean::parse(value.as_bytes()) + .map(|v| VCardParameterValue::Bool(v.0)) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Type => { + let value = value.into_string(); + VCardType::parse(value.as_bytes()) + .map(VCardParameterValue::Type) + .unwrap_or_else(|| VCardParameterValue::Text(value.into_owned())) + } + VCardParameterName::Group => { + self.group = Some(value.into_string().into_owned()); + continue; + } + _ => VCardParameterValue::Text(value.into_string().into_owned()), + }; + + self.params.push(VCardParameter { + name: param.clone(), + value, + }); + } + } + } +} diff --git a/vendor/calcard/src/jscontact/export/mod.rs b/vendor/calcard/src/jscontact/export/mod.rs new file mode 100644 index 00000000..3743ba4b --- /dev/null +++ b/vendor/calcard/src/jscontact/export/mod.rs @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + jscontact::{JSContactId, JSContactProperty, JSContactValue}, + vcard::VCard, +}; +use jmap_tools::{Key, Value}; + +pub mod convert; +pub mod entry; +pub mod params; +pub mod props; + +#[derive(Default)] +#[allow(clippy::type_complexity)] +struct State<'x, I, B> +where + I: JSContactId, + B: JSContactId, +{ + pub(super) vcard: VCard, + pub(super) converted_props: Vec<( + Vec>>, + Value<'x, JSContactProperty, JSContactValue>, + )>, + pub(super) converted_props_count: usize, + pub(super) language: Option, +} diff --git a/vendor/calcard/src/jscontact/export/params.rs b/vendor/calcard/src/jscontact/export/params.rs new file mode 100644 index 00000000..b878a34e --- /dev/null +++ b/vendor/calcard/src/jscontact/export/params.rs @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{IanaParse, IanaType}, + jscontact::{ + JSContactId, JSContactProperty, JSContactValue, + export::{State, props::convert_value}, + }, + vcard::{ + VCard, VCardEntry, VCardParameter, VCardParameterName, VCardParameterValue, VCardProperty, + VCardValueType, ValueType, + }, +}; +use jmap_tools::{Element, JsonPointer, Key, Property, Value}; +use std::borrow::Cow; + +impl<'x, I, B> State<'x, I, B> +where + I: JSContactId, + B: JSContactId, +{ + pub(super) fn insert_vcard(&mut self, path: &[JSContactProperty], mut entry: VCardEntry) { + if self.converted_props_count < self.converted_props.len() { + // Obtain propId + let mut prop_id = + if matches!(entry.name, VCardProperty::Member | VCardProperty::Related) { + entry.values.first().and_then(|v| v.as_text()) + } else { + entry.prop_id() + }; + + // Try mapping X-ABLabel + if let Some(prop_id_) = prop_id { + let mut remove_pos = None; + for (param_pos, param) in entry.params.iter().enumerate() { + if let (VCardParameterName::Label, VCardParameterValue::Text(label)) = + (¶m.name, ¶m.value) + { + if self.converted_props.iter().any(|(prop, _)| { + prop.len() == 3 + && prop[0].to_string() == path[0].to_string() + && prop[1] == prop_id_ + && prop[2] == Key::Property(JSContactProperty::Label) + }) { + self.insert_vcard( + &[path[0].clone(), JSContactProperty::Label], + VCardEntry::new(VCardProperty::Other("X-ABLabel".into())) + .with_value(label.to_string()), + ); + remove_pos = Some(param_pos); + } + break; + } + } + + if let Some(pos) = remove_pos { + entry.params.swap_remove(pos); + prop_id = + if matches!(entry.name, VCardProperty::Member | VCardProperty::Related) { + entry.values.first().and_then(|v| v.as_text()) + } else { + entry.prop_id() + }; + } + } + + let skip_tz_geo = matches!(entry.name, VCardProperty::Adr); + let mut matched_once = false; + + 'outer: for (keys, value) in self.converted_props.iter_mut() { + let is_localized_key = keys + .first() + .is_some_and(|k| matches!(k, Key::Property(JSContactProperty::Localizations))); + + if let Some(lang) = &self.language { + if !is_localized_key || keys.get(1).is_none_or(|k| &k.to_string() != lang) { + continue; + } + } else if is_localized_key { + continue; + } + if matches!(value, Value::Null) { + continue; + } + + for (pos, item) in path.iter().enumerate() { + if !keys + .iter() + .any(|k| matches!(k, Key::Property(p) if p == item)) + { + if pos == 0 && matched_once { + // Array is sorted, so if we didn't match the first item, + // we won't match any further. + break 'outer; + } else { + continue 'outer; + } + } else { + matched_once = true; + } + } + + if prop_id + .map(Key::Borrowed) + .is_none_or(|prop_id| keys.iter().any(|k| k == &prop_id)) + && (!skip_tz_geo + || !keys.iter().any(|k| { + matches!( + k, + Key::Property( + JSContactProperty::TimeZone | JSContactProperty::Coordinates + ) + ) + })) + { + entry.import_converted_properties(std::mem::take(value)); + self.converted_props_count += 1; + break; + } + } + } + + if let Some(lang) = &self.language { + entry.params.push(VCardParameter::language(lang.clone())); + } + + self.vcard.entries.push(entry); + } + + pub(super) fn insert_jsprop( + &mut self, + path: &[&str], + value: Value<'x, JSContactProperty, JSContactValue>, + ) { + let path = if let Some(lang) = &self.language { + JsonPointer::>::encode([ + JSContactProperty::Localizations::.to_string().as_ref(), + lang.as_str(), + JsonPointer::>::encode(path).as_str(), + ]) + } else { + JsonPointer::>::encode(path) + }; + + self.vcard.entries.push( + VCardEntry::new(VCardProperty::Jsprop) + .with_param(VCardParameter::jsptr(path)) + .with_value(serde_json::to_string(&value).unwrap_or_default()), + ); + } + + pub(super) fn import_properties( + &mut self, + props: Vec, JSContactValue>>, + ) { + for prop in props.into_iter().flat_map(|prop| prop.into_array()) { + let mut prop = prop.into_iter(); + let Some(name) = prop.next().and_then(|v| v.into_string()).map(|name| { + VCardProperty::parse(name.as_bytes()) + .unwrap_or(VCardProperty::Other(name.to_ascii_uppercase())) + }) else { + continue; + }; + let Some(params) = prop.next() else { + continue; + }; + let Some(value_type) = prop.next().and_then(|v| v.into_string()).map(|v| { + match VCardValueType::parse(v.as_bytes()) { + Some(v) => IanaType::Iana(v), + None => IanaType::Other(v.to_ascii_uppercase()), + } + }) else { + continue; + }; + + let (default_type, _) = name.default_types(); + let convert_type = value_type + .iana() + .map(|v| ValueType::Vcard(*v)) + .unwrap_or(default_type); + + let Some(values) = prop.next().and_then(|v| match v { + Value::Array(arr) => Some( + arr.into_iter() + .filter_map(|v| convert_value(v, &convert_type).ok()) + .collect::>(), + ), + v => convert_value(v, &convert_type).ok().map(|v| vec![v]), + }) else { + continue; + }; + + let mut entry = VCardEntry::new(name); + entry.import_jcard_params(params); + entry.values = values; + if convert_type != default_type { + entry.params.push(VCardParameter::value(value_type)); + } + self.vcard.entries.push(entry); + } + } + + pub(super) fn into_vcard(self) -> VCard { + self.vcard + } +} + +pub(crate) enum ParamValue<'x> { + Text(Cow<'x, str>), + Number(i64), + Bool(bool), +} + +impl<'x> ParamValue<'x> { + pub(crate) fn try_from_value(value: Value<'x, P, E>) -> Option { + match value { + Value::Str(s) => Some(Self::Text(s)), + Value::Number(n) => Some(Self::Number(n.cast_to_i64())), + Value::Bool(b) => Some(Self::Bool(b)), + Value::Element(e) => Some(Self::Text(e.to_cow().to_string().into())), + _ => None, + } + } + + pub(crate) fn into_string(self) -> Cow<'x, str> { + match self { + Self::Text(s) => s, + Self::Number(n) => n.to_string().into(), + Self::Bool(b) => if b { "true" } else { "false" }.to_string().into(), + } + } + + pub(crate) fn into_number(self) -> Result { + match self { + Self::Number(n) => Ok(n), + Self::Text(s) => s.parse().map_err(|_| Self::Text(s)), + _ => Err(self), + } + } +} diff --git a/vendor/calcard/src/jscontact/export/props.rs b/vendor/calcard/src/jscontact/export/props.rs new file mode 100644 index 00000000..46edbc35 --- /dev/null +++ b/vendor/calcard/src/jscontact/export/props.rs @@ -0,0 +1,399 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{CalendarScale, IanaParse, IanaType, PartialDateTime, parser::Integer}, + icalendar::Uri, + jscontact::{ + JSContactGrammaticalGender, JSContactId, JSContactKind, JSContactLevel, + JSContactPhoneticSystem, JSContactProperty, JSContactValue, + }, + vcard::{ + VCardGramGender, VCardKind, VCardLevel, VCardPhonetic, VCardSex, VCardType, VCardValue, + VCardValueType, ValueType, + }, +}; +use jmap_tools::{JsonPointerItem, Key, Map, Value}; +use std::{borrow::Cow, iter::Peekable, vec::IntoIter}; + +pub(super) fn build_path<'x, I, B>( + obj: &mut Value<'x, JSContactProperty, JSContactValue>, + mut ptr: Peekable>>>, + value: Value<'x, JSContactProperty, JSContactValue>, +) -> Option, JSContactValue>> +where + I: JSContactId, + B: JSContactId, +{ + if let Some(item) = ptr.next() { + match item { + JsonPointerItem::Root | JsonPointerItem::Wildcard => {} + JsonPointerItem::Key(key) => match obj { + Value::Object(obj) => { + return build_path( + obj.insert_or_get_mut( + key, + if matches!(ptr.peek(), Some(JsonPointerItem::Key(_))) { + Value::Object(Map::from(Vec::new())) + } else { + Value::Array(Vec::new()) + }, + ), + ptr, + value, + ); + } + Value::Null => { + *obj = Value::Object(Map::from(vec![(key, Value::Null)])); + return build_path( + &mut obj + .as_object_mut() + .unwrap() + .as_mut_vec() + .last_mut() + .unwrap() + .1, + ptr, + value, + ); + } + _ => {} + }, + JsonPointerItem::Number(idx) => { + if let Some(arr) = obj.as_array_mut() { + if (idx as usize) < arr.len() { + return build_path(&mut arr[idx as usize], ptr, value); + } + + if idx < 20 { + arr.resize_with(idx as usize + 1, || Value::Null); + return build_path(&mut arr[idx as usize], ptr, value); + } + } + } + } + Some(value) + } else { + *obj = value; + None + } +} + +#[allow(clippy::type_complexity)] +pub(super) fn convert_anniversary( + value: Value<'_, JSContactProperty, JSContactValue>, +) -> Result< + (PartialDateTime, Option), + Value<'_, JSContactProperty, JSContactValue>, +> +where + I: JSContactId, + B: JSContactId, +{ + let mut date = PartialDateTime::default(); + let mut calendar_scale = None; + let Some(object) = value.as_object() else { + return Err(value); + }; + + for (key, value) in object.as_vec() { + match key { + Key::Property(JSContactProperty::Day) => { + if let Value::Number(day) = value { + date.day = Some(day.cast_to_i64() as u8); + } + } + Key::Property(JSContactProperty::Month) => { + if let Value::Number(month) = value { + date.month = Some(month.cast_to_i64() as u8); + } + } + Key::Property(JSContactProperty::Year) => { + if let Value::Number(year) = value { + date.year = Some(year.cast_to_i64() as u16); + } + } + Key::Property(JSContactProperty::CalendarScale) => { + if let Value::Element(JSContactValue::CalendarScale(scale)) = value { + calendar_scale = Some(scale.clone()); + } + } + Key::Property(JSContactProperty::Utc) => { + if let Value::Element(JSContactValue::Timestamp(timestamp)) = value { + return Ok((PartialDateTime::from_utc_timestamp(*timestamp), None)); + } + } + _ => {} + } + } + + if date.year.is_some() || date.month.is_some() || date.day.is_some() { + Ok((date, calendar_scale)) + } else { + Err(value) + } +} + +pub(super) fn convert_value<'x, I, B>( + value: Value<'x, JSContactProperty, JSContactValue>, + value_type: &'_ ValueType, +) -> Result, JSContactValue>> +where + I: JSContactId, + B: JSContactId, +{ + match value { + Value::Element(e) => match e { + JSContactValue::Timestamp(t) => Ok(VCardValue::PartialDateTime( + PartialDateTime::from_utc_timestamp(t), + )), + JSContactValue::GrammaticalGender(g) => Ok(VCardValue::GramGender(match g { + JSContactGrammaticalGender::Animate => VCardGramGender::Animate, + JSContactGrammaticalGender::Common => VCardGramGender::Common, + JSContactGrammaticalGender::Feminine => VCardGramGender::Feminine, + JSContactGrammaticalGender::Inanimate => VCardGramGender::Inanimate, + JSContactGrammaticalGender::Masculine => VCardGramGender::Masculine, + JSContactGrammaticalGender::Neuter => VCardGramGender::Neuter, + })), + JSContactValue::Kind(k) => match k { + JSContactKind::Individual => Ok(VCardValue::Kind(VCardKind::Individual)), + JSContactKind::Group => Ok(VCardValue::Kind(VCardKind::Group)), + JSContactKind::Location => Ok(VCardValue::Kind(VCardKind::Location)), + JSContactKind::Org => Ok(VCardValue::Kind(VCardKind::Org)), + JSContactKind::Application => Ok(VCardValue::Kind(VCardKind::Application)), + JSContactKind::Device => Ok(VCardValue::Kind(VCardKind::Device)), + _ => Err(Value::Element(JSContactValue::Kind(k))), + }, + JSContactValue::Level(_) + | JSContactValue::Type(_) + | JSContactValue::Relation(_) + | JSContactValue::PhoneticSystem(_) + | JSContactValue::CalendarScale(_) + | JSContactValue::BlobId(_) + | JSContactValue::Id(_) + | JSContactValue::IdReference(_) => Err(Value::Element(e)), + }, + Value::Str(s) => { + match value_type { + ValueType::Kind => { + if let Some(kind) = VCardKind::parse(s.as_ref().as_bytes()) { + return Ok(VCardValue::Kind(kind)); + } + } + ValueType::Sex => { + if let Some(sex) = VCardSex::parse(s.as_ref().as_bytes()) { + return Ok(VCardValue::Sex(sex)); + } + } + ValueType::GramGender => { + if let Some(gender) = VCardGramGender::parse(s.as_ref().as_bytes()) { + return Ok(VCardValue::GramGender(gender)); + } + } + ValueType::Vcard(typ) => match typ { + VCardValueType::Boolean => { + if s.eq_ignore_ascii_case("true") { + return Ok(VCardValue::Boolean(true)); + } else if s.eq_ignore_ascii_case("false") { + return Ok(VCardValue::Boolean(false)); + } + } + VCardValueType::Date => { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date(&mut s.as_ref().as_bytes().iter().peekable()); + if !dt.is_null() { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::DateAndOrTime => { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date_and_or_time( + &mut s.as_ref().as_bytes().iter().peekable(), + ); + if !dt.is_null() { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::DateTime => { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date_time(&mut s.as_ref().as_bytes().iter().peekable()); + if !dt.is_null() { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::Time => { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_time(&mut s.as_ref().as_bytes().iter().peekable(), false); + if !dt.is_null() { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::Timestamp => { + let mut dt = PartialDateTime::default(); + if dt.parse_timestamp(&mut s.as_ref().as_bytes().iter().peekable(), true) { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::UtcOffset => { + let mut dt = PartialDateTime::default(); + dt.parse_zone(&mut s.as_ref().as_bytes().iter().peekable()); + if !dt.is_null() { + return Ok(VCardValue::PartialDateTime(dt)); + } + } + VCardValueType::Float => { + if let Ok(float) = s.as_ref().parse::() { + return Ok(VCardValue::Float(float)); + } + } + VCardValueType::Integer => { + if let Some(integer) = Integer::parse(s.as_ref().as_bytes()) { + return Ok(VCardValue::Integer(integer.0)); + } + } + VCardValueType::Uri => { + return Ok(match Uri::parse(s) { + Uri::Data(data) => VCardValue::Binary(data), + Uri::Location(text) => VCardValue::Text(text), + }); + } + VCardValueType::LanguageTag | VCardValueType::Text => (), + }, + } + + Ok(VCardValue::Text(s.into_owned())) + } + Value::Bool(b) => Ok(VCardValue::Boolean(b)), + Value::Number(n) => match n.try_cast_to_i64() { + Ok(i) => Ok(VCardValue::Integer(i)), + Err(f) => Ok(VCardValue::Float(f)), + }, + value => Err(value), + } +} + +pub(super) fn convert_types( + value: Value<'_, JSContactProperty, JSContactValue>, + is_context: bool, +) -> Option>> +where + I: JSContactId, + B: JSContactId, +{ + let mut types = Vec::new(); + for typ in value.into_expanded_boolean_set() { + let typ = typ.to_string(); + match VCardType::parse(typ.as_ref().as_bytes()) { + Some(typ) => types.push(IanaType::Iana(typ)), + None => { + if is_context && typ.eq_ignore_ascii_case("private") { + types.push(IanaType::Iana(VCardType::Home)); + } else if !is_context && typ.eq_ignore_ascii_case("mobile") { + types.push(IanaType::Iana(VCardType::Cell)); + } else { + types.push(IanaType::Other(typ.to_ascii_uppercase())); + } + } + } + } + if !types.is_empty() { Some(types) } else { None } +} + +pub(super) fn map_kind( + value: &Value<'_, JSContactProperty, JSContactValue>, + types: impl IntoIterator, +) -> Option +where + I: JSContactId, + B: JSContactId, +{ + value + .as_object() + .and_then(|obj| obj.get(&Key::Property(JSContactProperty::Kind))) + .and_then(|v| match v { + Value::Element(JSContactValue::Kind(kind)) => { + types.into_iter().find_map(|(js_kind, vcard_property)| { + if js_kind == *kind { + Some(vcard_property) + } else { + None + } + }) + } + + _ => None, + }) +} + +impl TryFrom, JSContactValue>> + for IanaType +where + I: JSContactId, + B: JSContactId, +{ + type Error = (); + fn try_from( + value: Value<'_, JSContactProperty, JSContactValue>, + ) -> Result { + match value { + Value::Element(JSContactValue::PhoneticSystem(system)) => { + Ok(IanaType::Iana(match system { + JSContactPhoneticSystem::Ipa => VCardPhonetic::Ipa, + JSContactPhoneticSystem::Jyut => VCardPhonetic::Jyut, + JSContactPhoneticSystem::Piny => VCardPhonetic::Piny, + JSContactPhoneticSystem::Script => VCardPhonetic::Script, + })) + } + Value::Str(text) => match VCardPhonetic::parse(text.as_ref().as_bytes()) { + Some(phonetic) => Ok(IanaType::Iana(phonetic)), + None => Ok(IanaType::Other(text.to_ascii_uppercase())), + }, + _ => Err(()), + } + } +} + +impl TryFrom, JSContactValue>> + for IanaType +where + I: JSContactId, + B: JSContactId, +{ + type Error = (); + + fn try_from( + value: Value<'_, JSContactProperty, JSContactValue>, + ) -> Result { + match value { + Value::Element(JSContactValue::Level(level)) => Ok(IanaType::Iana(match level { + JSContactLevel::High => VCardLevel::High, + JSContactLevel::Low => VCardLevel::Low, + JSContactLevel::Medium => VCardLevel::Medium, + })), + Value::Str(text) => match VCardLevel::parse(text.as_ref().as_bytes()) { + Some(level) => Ok(IanaType::Iana(level)), + None => Ok(IanaType::Other(text.to_ascii_uppercase())), + }, + _ => Err(()), + } + } +} + +pub(super) fn find_text_param<'x, I, B>( + value: &'x Value<'x, JSContactProperty, JSContactValue>, + name: &str, +) -> Option> +where + I: JSContactId, + B: JSContactId, +{ + value + .as_object() + .and_then(|obj| obj.get(&Key::Property(JSContactProperty::Parameters))) + .and_then(|obj| obj.as_object()) + .and_then(|obj| obj.get_ignore_case(name)) + .and_then(|obj| obj.as_str()) +} diff --git a/vendor/calcard/src/jscontact/import/convert.rs b/vendor/calcard/src/jscontact/import/convert.rs new file mode 100644 index 00000000..a3456a59 --- /dev/null +++ b/vendor/calcard/src/jscontact/import/convert.rs @@ -0,0 +1,1276 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + jscontact::{ + JSContact, JSContactId, JSContactKind, JSContactProperty, JSContactValue, + import::{ConversionOptions, EntryState, GetObjectOrCreate, State}, + }, + vcard::{ + Jscomp, VCard, VCardParameter, VCardParameterName, VCardParameterValue, VCardProperty, + VCardValue, VCardValueType, + }, +}; +use jmap_tools::{JsonPointer, Key, Map, Property, Value}; + +impl VCard { + pub fn into_jscontact(self) -> JSContact<'static, I, B> + where + I: JSContactId, + B: JSContactId, + { + self.into_jscontact_with_options(ConversionOptions::default()) + } + + pub fn into_jscontact_with_options( + mut self, + options: ConversionOptions, + ) -> JSContact<'static, I, B> + where + I: JSContactId, + B: JSContactId, + { + let mut state = State::new(&mut self, options.include_vcard_parameters); + + for entry in self.entries { + let mut entry = EntryState::new(entry); + + match &entry.entry.name { + VCardProperty::Kind => { + if !state.has_property(JSContactProperty::Kind) + && let Some(kind) = entry.to_kind() + { + state + .entries + .insert(Key::Property(JSContactProperty::Kind), kind); + entry.set_converted_to::(&[JSContactProperty::Kind:: + .to_string() + .as_ref()]); + } + } + VCardProperty::Source => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::PropId, + VCardParameterName::Pref, + VCardParameterName::Mediatype, + ], + JSContactProperty::Directories, + JSContactProperty::Uri, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(JSContactKind::Entry)), + )], + ); + } + VCardProperty::OrgDirectory => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::PropId, + VCardParameterName::Pref, + VCardParameterName::Index, + VCardParameterName::Label, + ], + JSContactProperty::Directories, + JSContactProperty::Uri, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(JSContactKind::Directory)), + )], + ); + } + VCardProperty::Anniversary | VCardProperty::Bday | VCardProperty::Deathdate => { + let kind = match &entry.entry.name { + VCardProperty::Anniversary => JSContactKind::Wedding, + VCardProperty::Bday => JSContactKind::Birth, + VCardProperty::Deathdate => JSContactKind::Death, + _ => unreachable!(), + }; + + state.map_named_entry( + &mut entry, + &[VCardParameterName::PropId, VCardParameterName::Calscale], + JSContactProperty::Anniversaries, + JSContactProperty::Date, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + )], + ); + } + VCardProperty::Birthplace | VCardProperty::Deathplace => { + // Only text and GEO can be mapped + let is_geo = matches!(entry.entry.values.first(), Some(VCardValue::Text(uri)) if uri.starts_with("geo:")); + if (is_geo || !entry.entry.is_type(&VCardValueType::Uri)) + && let Some(text) = entry.to_text() + { + // Extract language and value type + let mut params = state.extract_params( + &mut entry.entry.params, + &[VCardParameterName::Language, VCardParameterName::PropId], + ); + + let prop_id = params.prop_id(); + let alt_id = params.alt_id(); + + let kind = Value::Element(JSContactValue::Kind( + if entry.entry.name == VCardProperty::Birthplace { + JSContactKind::Birth + } else { + JSContactKind::Death + }, + )); + + let patch_prop = if params.language.is_some() { + &entry.entry.name + } else if entry.entry.name == VCardProperty::Birthplace { + &VCardProperty::Bday + } else { + &VCardProperty::Deathdate + }; + let mut patch_id = prop_id + .as_deref() + .filter(|prop_id| state.has_prop_id(patch_prop, prop_id)) + .or_else(|| { + state.find_prop_id( + patch_prop, + entry.entry.group.as_deref(), + alt_id.as_deref(), + ) + }) + .map(|prop_id| prop_id.to_string()); + + if patch_id.is_none() && params.language.is_some() { + entry + .entry + .params + .push(VCardParameter::language(params.language.take().unwrap())); + } + + let entries = + state.get_mut_object_or_insert(JSContactProperty::Anniversaries); + + if let Some(lang) = params.language() { + let path = format!( + "{}/{}/{}/{}", + JSContactProperty::Anniversaries::.to_cow().as_ref(), + patch_id.unwrap(), + JSContactProperty::Place::.to_cow().as_ref(), + JSContactProperty::Full::.to_cow().as_ref() + ); + + entry.set_converted_to::(&[ + JSContactProperty::Localizations::.to_cow().as_ref(), + lang.as_str(), + path.as_str(), + ]); + + state + .localizations + .entry(lang) + .or_default() + .push((path, text)); + } else { + // Place needs to be wrapped in an option to dance around the borrow checker + let prop_name = if !is_geo { + JSContactProperty::Full + } else { + JSContactProperty::Coordinates + }; + let mut place = + Some(Map::from(vec![(Key::Property(prop_name.clone()), text)])); + + if let Some(anniversary) = patch_id.clone().and_then(|patch_id| { + entries + .get_mut(&Key::Owned(patch_id)) + .and_then(|v| v.as_object_mut()) + }) { + anniversary.insert( + Key::Property(JSContactProperty::Place), + Value::Object(place.take().unwrap()), + ); + } + + if place.is_some() + && let Some((key, anniversary)) = + entries.iter_mut().find_map(|(k, v)| { + let obj = v.as_object_mut()?; + if obj.iter().any(|(k, v)| { + k == &Key::Property(JSContactProperty::Kind) + && v == &kind + }) { + Some((k, obj)) + } else { + None + } + }) + { + anniversary.insert( + Key::Property(JSContactProperty::Place), + Value::Object(place.take().unwrap()), + ); + patch_id = Some(key.to_string().into_owned()); + } + + if let Some(place) = place { + patch_id = + entries.insert_named(prop_id, Value::Object(place)).into(); + } + + let patch_id = patch_id.unwrap(); + + entry.set_converted_to::(&[ + JSContactProperty::Anniversaries::.to_cow().as_ref(), + patch_id.as_ref(), + JSContactProperty::Place::.to_cow().as_ref(), + prop_name.to_cow().as_ref(), + ]); + + state.track_prop( + &entry.entry, + JSContactProperty::Anniversaries, + alt_id, + patch_id, + ); + } + } + } + VCardProperty::Fn => { + if (!state.has_fn + || (entry + .non_default_language(state.default_language.as_deref()) + .is_some() + && !state.has_fn_localization)) + && let Some(text) = entry.to_text() + { + if let Some(lang) = state + .extract_params( + &mut entry.entry.params, + &[VCardParameterName::Language], + ) + .language + { + let path = format!( + "{}/{}", + JSContactProperty::Name::.to_cow().as_ref(), + JSContactProperty::Full::.to_cow().as_ref() + ); + + entry.set_converted_to::(&[ + JSContactProperty::Localizations::.to_cow().as_ref(), + lang.as_str(), + path.as_str(), + ]); + state.has_fn_localization = true; + state + .localizations + .entry(lang) + .or_default() + .push((path, text)); + } else { + state.has_fn = true; + state + .get_mut_object_or_insert(JSContactProperty::Name) + .insert(Key::Property(JSContactProperty::Full), text); + entry.set_converted_to::(&[ + JSContactProperty::Name::.to_cow().as_ref(), + JSContactProperty::Full::.to_cow().as_ref(), + ]); + } + } + } + VCardProperty::N => { + /* + Only process this N component if it matches the main ALTID and: + - It is the first N component + - Or it has a language that has not been processed yet + */ + if state.name_alt_id.as_deref() == entry.entry.alt_id() + && (!state.has_n + || (entry + .non_default_language(state.default_language.as_deref()) + .is_some() + && !state.has_n_localization)) + { + let mut params = state.extract_params( + &mut entry.entry.params, + &[ + VCardParameterName::Language, + VCardParameterName::Phonetic, + VCardParameterName::Script, + VCardParameterName::SortAs, + VCardParameterName::Jscomps, + ], + ); + + if params.language.is_some() && !state.has_n { + entry + .entry + .params + .push(VCardParameter::language(params.language.take().unwrap())); + } + + /* + + 0 = family names (also known as surnames); + 1 = given names; + 2 = additional names; + 3 = honorific prefixes; + 4 = honorific suffixes; + 5 = secondary surname; + 6 = generation + + */ + + let mut default_separator = None; + let mut is_ordered = false; + let mut components = Vec::new(); + + if !params.jscomps.is_empty() { + let mut is_valid = true; + let mut max_pos = 0; + + for (pos, jscomp) in + std::mem::take(&mut params.jscomps).into_iter().enumerate() + { + match jscomp { + Jscomp::Entry { position, value } => { + if let Some(kind) = + JSContactKind::from_vcard_n_pos(position as usize) + { + let value = + match entry.entry.values.get(position as usize) { + Some(VCardValue::Text(text)) if value == 0 => { + Some(text.as_str()) + } + Some(VCardValue::Component(values)) => values + .get(value as usize) + .map(|v| v.as_str()), + _ => None, + }; + + max_pos = std::cmp::max(max_pos, position); + if let Some(value) = value { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(value.to_string().into()), + ), + ]))); + continue; + } + } + + is_valid = false; + break; + } + Jscomp::Separator(sep) => { + if !sep.is_empty() { + if pos > 0 { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind( + JSContactKind::Separator, + )), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(sep.into()), + ), + ]))); + } else { + default_separator = sep.into(); + } + } + } + } + } + + if is_valid + && !components.is_empty() + && max_pos <= (entry.entry.values.len() - 1) as u32 + { + is_ordered = true; + entry.entry.values.clear(); + } + } + + if !is_ordered { + let mut components_ = Vec::with_capacity(7); + for (comp_id, value) in entry.entry.values.iter().enumerate() { + if let Some(kind) = JSContactKind::from_vcard_n_pos(comp_id) { + match value { + VCardValue::Text(text) if !text.is_empty() => { + components_.push((kind, text.as_str())); + } + VCardValue::Component(text_list) => { + for text in text_list { + components_.push((kind, text.as_str())); + } + } + _ => {} + } + } + } + + components = components_ + .iter() + .filter(|(kind, value)| { + match kind { + JSContactKind::Credential => { + // 'credential' From vCard: ignore any value that also occurs in the Generation component. + !components_ + .contains(&(JSContactKind::Generation, value)) + } + JSContactKind::Surname => { + // 'surname': From vCard: ignore any value that also occurs in the Secondary surname component. + !components_.contains(&(JSContactKind::Surname2, value)) + } + _ => true, + } + }) + .map(|(kind, value)| { + Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(*kind)), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(value.to_string().into()), + ), + ])) + }) + .collect::>(); + } + + let name = state + .entries + .get_mut_object_or_insert(JSContactProperty::Name); + + if let Some(lang) = params.language() { + let path = format!( + "{}/{}", + JSContactProperty::Name::.to_cow().as_ref(), + JSContactProperty::Components::.to_cow().as_ref(), + ); + + entry.set_converted_to::(&[ + JSContactProperty::Localizations::.to_cow().as_ref(), + lang.as_str(), + path.as_str(), + ]); + + state.has_n_localization = true; + let locale = state.localizations.entry(lang).or_default(); + locale.push((path, Value::Array(components))); + + for (prop, value) in params.into_iter(&entry.entry.name) { + locale.push(( + format!( + "{}/{}", + JSContactProperty::Name::.to_cow().as_ref(), + prop.to_string() + ), + value, + )); + } + } else { + state.has_n = true; + name.extend(params.into_iter(&entry.entry.name)); + name.insert( + Key::Property(JSContactProperty::Components), + Value::Array(components), + ); + if let Some(default_separator) = default_separator { + name.insert_unchecked( + Key::Property(JSContactProperty::DefaultSeparator), + Value::Str(default_separator.into()), + ); + } + if is_ordered { + name.insert_unchecked( + Key::Property(JSContactProperty::IsOrdered), + Value::Bool(true), + ); + } + entry.set_converted_to::(&[ + JSContactProperty::Name::.to_cow().as_ref(), + JSContactProperty::Components::.to_cow().as_ref(), + ]); + } + } + } + VCardProperty::Gramgender => { + if !state.has_gram_gender + && let Some(gram_gender) = entry.to_gram_gender() + { + state.extract_params(&mut entry.entry.params, &[]); + state + .get_mut_object_or_insert(JSContactProperty::SpeakToAs) + .insert(JSContactProperty::GrammaticalGender, gram_gender); + state.has_gram_gender = true; + entry.set_converted_to::(&[ + JSContactProperty::SpeakToAs::.to_cow().as_ref(), + JSContactProperty::GrammaticalGender::.to_cow().as_ref(), + ]); + } + } + VCardProperty::Pronouns => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Language, + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Pref, + ], + JSContactProperty::SpeakToAs, + JSContactProperty::Pronouns, + [], + ); + } + VCardProperty::Nickname => { + for value in std::mem::take(&mut entry.entry.values) { + let mut value_entry = entry.clone(); + value_entry.entry.values = vec![value]; + + state.map_named_entry( + &mut value_entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Language, + ], + JSContactProperty::Nicknames, + JSContactProperty::Name, + [], + ); + state.add_conversion_props(value_entry); + } + continue; + } + VCardProperty::Photo | VCardProperty::Logo | VCardProperty::Sound => { + let kind = match &entry.entry.name { + VCardProperty::Photo => JSContactKind::Photo, + VCardProperty::Logo => JSContactKind::Logo, + VCardProperty::Sound => JSContactKind::Sound, + _ => unreachable!(), + }; + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Mediatype, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + ], + JSContactProperty::Media, + JSContactProperty::Uri, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + )], + ); + } + VCardProperty::Adr => { + let mut params = state.extract_params( + &mut entry.entry.params, + &[ + VCardParameterName::Language, + VCardParameterName::Phonetic, + VCardParameterName::Script, + VCardParameterName::Label, + VCardParameterName::Geo, + VCardParameterName::Tz, + VCardParameterName::Cc, + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Pref, + VCardParameterName::Type, + VCardParameterName::Jscomps, + ], + ); + + let prop_id = params.prop_id(); + let alt_id = params.alt_id(); + + // Locate the address to patch or reset language + let addr_patch = if params.language.is_some() { + let addr_patch = prop_id + .as_deref() + .filter(|prop_id| state.has_prop_id(&entry.entry.name, prop_id)) + .or_else(|| { + state.find_prop_id( + &entry.entry.name, + entry.entry.group.as_deref(), + alt_id.as_deref(), + ) + }); + + if addr_patch.is_none() { + entry + .entry + .params + .push(VCardParameter::language(params.language().unwrap())); + } + addr_patch + } else { + None + }; + + /* + + 0 = ADR-component-pobox ";" + 1 = ADR-component-ext ";" + 2 = ADR-component-street ";" + 3 = ADR-component-locality ";" + 4 = ADR-component-region ";" + 5 = ADR-component-code ";" + 6 = ADR-component-country ";" + 7 = ADR-component-room ";" + 8 = ADR-component-apartment ";" + 9 = ADR-component-floor ";" + 10 = ADR-component-streetnumber ";" + 11 = ADR-component-streetname ";" + 12 = ADR-component-building ";" + 13 = ADR-component-block ";" + 14 = ADR-component-subdistrict ";" + 15 = ADR-component-district ";" + 16 = ADR-component-landmark ";" + 17 = ADR-component-direction ";" + + */ + + let mut default_separator = None; + let mut is_ordered = false; + let mut components = Vec::new(); + + if !params.jscomps.is_empty() { + let mut is_valid = true; + let mut max_pos = 0; + + for (pos, jscomp) in + std::mem::take(&mut params.jscomps).into_iter().enumerate() + { + match jscomp { + Jscomp::Entry { position, value } => { + if let Some(kind) = + JSContactKind::from_vcard_adr_pos(position as usize) + { + let value = match entry.entry.values.get(position as usize) + { + Some(VCardValue::Text(text)) if value == 0 => { + Some(text.as_str()) + } + Some(VCardValue::Component(values)) => { + values.get(value as usize).map(|v| v.as_str()) + } + _ => None, + }; + + max_pos = std::cmp::max(max_pos, position); + if let Some(value) = value { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(value.to_string().into()), + ), + ]))); + continue; + } + } + + is_valid = false; + break; + } + Jscomp::Separator(sep) => { + if !sep.is_empty() { + if pos > 0 { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind( + JSContactKind::Separator, + )), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(sep.into()), + ), + ]))); + } else { + default_separator = sep.into(); + } + } + } + } + } + + if is_valid + && !components.is_empty() + && max_pos <= (entry.entry.values.len() - 1) as u32 + { + is_ordered = true; + entry.entry.values.clear(); + } + } + + if !is_ordered { + components.clear(); + + let is_rfc9554_adr = entry.entry.values.iter().skip(7).any(|v| match v { + VCardValue::Text(text) => !text.is_empty(), + VCardValue::Component(text_list) => !text_list.is_empty(), + _ => false, + }); + + for (pos, value) in entry.entry.values.drain(..).enumerate() { + if is_rfc9554_adr && (pos == 1 || pos == 2) { + // From vCard: ignore if the ADR structured value is of the format defined in [RFC9554]. Otherwise, convert to "apartment". + // From vCard: ignore if the ADR structured value is of the format defined in [RFC9554]. Otherwise, convert to "name". + continue; + } + + if let Some(kind) = JSContactKind::from_vcard_adr_pos(pos) { + match value { + VCardValue::Text(text) if !text.is_empty() => { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(text.into()), + ), + ]))); + } + VCardValue::Component(text_list) => { + for text in text_list { + components.push(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + ), + ( + Key::Property(JSContactProperty::Value), + Value::Str(text.into()), + ), + ]))); + } + } + _ => {} + } + } + } + } + + if let Some(lang) = params.language() { + let path = format!( + "{}/{}/{}", + JSContactProperty::Addresses::.to_cow().as_ref(), + addr_patch.unwrap(), + JSContactProperty::Components::.to_cow().as_ref(), + ); + entry.set_converted_to::(&[ + JSContactProperty::Localizations::.to_cow().as_ref(), + lang.as_str(), + path.as_str(), + ]); + + let locale = state.localizations.entry(lang).or_default(); + + let base_path = path.rsplit_once('/').unwrap().0; + for (prop_name, value) in params.into_iter(&entry.entry.name) { + locale + .push((format!("{}/{}", base_path, prop_name.to_string()), value)); + } + + locale.push((path, Value::Array(components))); + } else { + let entries = state.get_mut_object_or_insert(JSContactProperty::Addresses); + let mut addr = Map::from(Vec::with_capacity(4)); + + addr.extend(params.into_iter(&entry.entry.name)); + addr.insert_unchecked( + Key::Property(JSContactProperty::Components), + Value::Array(components), + ); + if let Some(default_separator) = default_separator { + addr.insert_unchecked( + Key::Property(JSContactProperty::DefaultSeparator), + Value::Str(default_separator.into()), + ); + } + if is_ordered { + addr.insert_unchecked( + Key::Property(JSContactProperty::IsOrdered), + Value::Bool(true), + ); + } + + let prop_id = entries.insert_named(prop_id, Value::Object(addr)); + + entry.set_converted_to::(&[ + JSContactProperty::Addresses::.to_cow().as_ref(), + prop_id.as_str(), + JSContactProperty::Components::.to_cow().as_ref(), + ]); + + state.track_prop( + &entry.entry, + JSContactProperty::Addresses, + alt_id, + prop_id, + ); + } + } + VCardProperty::Email => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + ], + JSContactProperty::Emails, + JSContactProperty::Address, + [], + ); + } + VCardProperty::Impp => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + VCardParameterName::ServiceType, + VCardParameterName::Username, + ], + JSContactProperty::OnlineServices, + JSContactProperty::Uri, + [], + ); + entry.set_map_name(); + } + VCardProperty::Lang => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + ], + JSContactProperty::PreferredLanguages, + JSContactProperty::Language, + [], + ); + } + VCardProperty::Language | VCardProperty::Prodid | VCardProperty::Uid => { + let key = Key::Property(match &entry.entry.name { + VCardProperty::Language => JSContactProperty::Language, + VCardProperty::Prodid => JSContactProperty::ProdId, + VCardProperty::Uid => JSContactProperty::Uid, + _ => unreachable!(), + }); + + if !state.entries.contains_key(&key) + && let Some(text) = entry.to_text() + { + entry.set_converted_to::(&[key.to_string().as_ref()]); + state.entries.insert(key, text); + } + } + VCardProperty::Socialprofile => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + VCardParameterName::ServiceType, + VCardParameterName::Username, + ], + JSContactProperty::OnlineServices, + JSContactProperty::Uri, + [], + ); + } + VCardProperty::Tel => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + ], + JSContactProperty::Phones, + JSContactProperty::Number, + [], + ); + } + VCardProperty::ContactUri => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + VCardParameterName::Mediatype, + ], + JSContactProperty::Links, + JSContactProperty::Uri, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(JSContactKind::Contact)), + )], + ); + } + VCardProperty::Title => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Language, + ], + JSContactProperty::Titles, + JSContactProperty::Name, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(JSContactKind::Title)), + )], + ); + } + VCardProperty::Role => { + let prop_id = state + .find_prop_id( + &VCardProperty::Org, + entry.entry.group.as_deref(), + entry.entry.alt_id(), + ) + .map(|s| s.to_string()); + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Language, + ], + JSContactProperty::Titles, + JSContactProperty::Name, + [ + Some(( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(JSContactKind::Role)), + )), + prop_id.map(|prop_id| { + ( + Key::Property(JSContactProperty::OrganizationId), + Value::Str(prop_id.into()), + ) + }), + ] + .into_iter() + .flatten(), + ); + } + VCardProperty::Hobby | VCardProperty::Interest | VCardProperty::Expertise => { + let kind = match &entry.entry.name { + VCardProperty::Expertise => JSContactKind::Expertise, + VCardProperty::Hobby => JSContactKind::Hobby, + VCardProperty::Interest => JSContactKind::Interest, + _ => unreachable!(), + }; + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Level, + VCardParameterName::Index, + VCardParameterName::Label, + VCardParameterName::PropId, + VCardParameterName::Altid, + VCardParameterName::Language, + ], + JSContactProperty::PersonalInfo, + JSContactProperty::Value, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + )], + ); + } + VCardProperty::Org => { + let units = entry + .text_parts_borrowed() + .skip(1) + .map(|unit| { + Value::Object(Map::from(vec![( + Key::Property(JSContactProperty::Name), + Value::Str(unit.to_string().into()), + )])) + }) + .collect::>(); + + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::PropId, + VCardParameterName::SortAs, + ], + JSContactProperty::Organizations, + JSContactProperty::Name, + [(!units.is_empty()).then_some({ + (Key::Property(JSContactProperty::Units), Value::Array(units)) + })] + .into_iter() + .flatten(), + ); + } + VCardProperty::Member | VCardProperty::Categories => { + let key = if entry.entry.name == VCardProperty::Member { + JSContactProperty::Members + } else { + JSContactProperty::Keywords + }; + + entry.set_converted_to::(&[key.to_cow().as_ref()]); + + let obj = state.get_mut_object_or_insert(key); + + for key in entry.entry.values.drain(..).filter_map(|v| v.into_text()) { + obj.insert(Key::from(key), Value::Bool(true)); + } + } + VCardProperty::Created | VCardProperty::Rev => { + let key = Key::Property(match &entry.entry.name { + VCardProperty::Created => JSContactProperty::Created, + VCardProperty::Rev => JSContactProperty::Updated, + _ => unreachable!(), + }); + + if !state.entries.contains_key(&key) + && let Some(text) = entry.to_timestamp() + { + entry.set_converted_to::(&[key.to_string().as_ref()]); + state.entries.insert(key, text); + } + } + VCardProperty::Note => { + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Author, + VCardParameterName::AuthorName, + VCardParameterName::Created, + VCardParameterName::Language, + VCardParameterName::PropId, + ], + JSContactProperty::Notes, + JSContactProperty::Note, + [], + ); + } + VCardProperty::Url | VCardProperty::Key | VCardProperty::Caladruri => { + let prop = match &entry.entry.name { + VCardProperty::Url => JSContactProperty::Links, + VCardProperty::Key => JSContactProperty::CryptoKeys, + VCardProperty::Caladruri => JSContactProperty::SchedulingAddresses, + _ => unreachable!(), + }; + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + VCardParameterName::Mediatype, + ], + prop, + JSContactProperty::Uri, + [], + ); + } + VCardProperty::Fburl | VCardProperty::Caluri => { + let kind = match &entry.entry.name { + VCardProperty::Fburl => JSContactKind::FreeBusy, + VCardProperty::Caluri => JSContactKind::Calendar, + _ => unreachable!(), + }; + state.map_named_entry( + &mut entry, + &[ + VCardParameterName::Type, + VCardParameterName::Pref, + VCardParameterName::PropId, + VCardParameterName::Label, + VCardParameterName::Mediatype, + ], + JSContactProperty::Calendars, + JSContactProperty::Uri, + [( + Key::Property(JSContactProperty::Kind), + Value::Element(JSContactValue::Kind(kind)), + )], + ); + } + VCardProperty::Related => { + if let Some(text) = entry.to_string() { + let mut params = state + .extract_params(&mut entry.entry.params, &[VCardParameterName::Type]); + entry.set_converted_to::(&[ + JSContactProperty::RelatedTo::.to_cow().as_ref(), + text.as_ref(), + ]); + state + .get_mut_object_or_insert(JSContactProperty::RelatedTo) + .insert( + Key::from(text), + Value::Object(Map::from(vec![( + Key::Property(JSContactProperty::Relation), + Value::Object(Map::from_iter(params.types().into_iter().map( + |t| { + ( + Key::Owned(t.into_string().to_ascii_lowercase()), + Value::Bool(true), + ) + }, + ))), + )])), + ); + } + } + VCardProperty::Tz | VCardProperty::Geo => { + let (key, value) = match &entry.entry.name { + VCardProperty::Tz => { + (Key::Property(JSContactProperty::TimeZone), entry.to_tz()) + } + VCardProperty::Geo => ( + Key::Property(JSContactProperty::Coordinates), + entry.to_text(), + ), + _ => unreachable!(), + }; + + if let Some(value) = value { + let prop_id = state + .find_prop_id( + &VCardProperty::Adr, + entry.entry.group.as_deref(), + entry.entry.alt_id(), + ) + .map(|s| s.to_string()); + + entry.set_map_name(); + + let addresses = + state.get_mut_object_or_insert(JSContactProperty::Addresses); + if let Some(addr) = prop_id + .clone() + .and_then(|prop_id| addresses.get_mut(&Key::Owned(prop_id))) + .and_then(|v| v.as_object_mut()) + .filter(|v| !v.contains_key(&key)) + { + entry.set_converted_to::(&[ + JSContactProperty::Addresses::.to_cow().as_ref(), + prop_id.clone().unwrap().as_str(), + key.to_string().as_ref(), + ]); + addr.insert(key, value); + } else { + let prop_id = addresses.insert_named( + None, + Value::Object(Map::from(vec![(key.clone(), value)])), + ); + entry.set_converted_to::(&[ + JSContactProperty::Addresses::.to_cow().as_ref(), + prop_id.as_str(), + key.to_string().as_ref(), + ]); + } + } + } + VCardProperty::Other(name) + if name.eq_ignore_ascii_case("X-ABLabel") && entry.entry.group.is_some() => + { + if let Some(prop) = state.find_entry_by_group(entry.entry.group.as_deref()) { + let prop_id = prop.prop_id.to_string(); + let prop_js = Key::Property(prop.prop_js.clone()); + + if let Some(obj) = state + .entries + .get_mut(&prop_js) + .and_then(|v| v.as_object_mut()) + .and_then(|v| v.get_mut(&Key::Owned(prop_id.clone()))) + .and_then(|v| v.as_object_mut()) + .filter(|v| !v.contains_key(&Key::Property(JSContactProperty::Label))) + && let Some(value) = entry.to_text() + { + obj.insert_unchecked(Key::Property(JSContactProperty::Label), value); + entry.set_map_name(); + entry.set_converted_to::(&[ + prop_js.to_string().as_ref(), + prop_id.as_str(), + JSContactProperty::Label::.to_cow().as_ref(), + ]); + } + } + } + VCardProperty::Jsprop => { + if let Some(VCardParameter { + name: VCardParameterName::Jsptr, + value: VCardParameterValue::Text(ptr), + }) = entry.entry.params.first() + { + let ptr = JsonPointer::>::parse(ptr); + + if let Some(VCardValue::Text(text)) = entry.entry.values.first() + && let Ok(jscontact) = JSContact::parse(text) + { + state.patch_objects.push((ptr, jscontact.0.into_owned())); + continue; + } + } + } + VCardProperty::Version + | VCardProperty::Xml + | VCardProperty::Gender + | VCardProperty::Clientpidmap + | VCardProperty::Other(_) => (), + VCardProperty::Begin | VCardProperty::End => { + continue; + } + } + + state.add_conversion_props(entry); + } + + state.into_jscontact() + } +} diff --git a/vendor/calcard/src/jscontact/import/entry.rs b/vendor/calcard/src/jscontact/import/entry.rs new file mode 100644 index 00000000..fafa1c4e --- /dev/null +++ b/vendor/calcard/src/jscontact/import/entry.rs @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{IanaString, IanaType, writer::write_jscomps}, + jscontact::{ + JSContactGrammaticalGender, JSContactId, JSContactKind, JSContactProperty, JSContactType, + JSContactValue, + import::{EntryState, VCardParams}, + }, + vcard::{ + VCardEntry, VCardGramGender, VCardKind, VCardParameterName, VCardValue, VCardValueType, + }, +}; +use jmap_tools::{JsonPointer, Key, Map, Value}; +use std::borrow::Cow; + +#[allow(clippy::wrong_self_convention)] +impl EntryState { + pub(super) fn new(entry: VCardEntry) -> Self { + Self { + entry, + converted_to: None, + map_name: false, + } + } + + pub(super) fn jcal_parameters( + &mut self, + params: &mut VCardParams, + value_type: &mut Option>, + ) { + if self.entry.params.is_empty() && self.entry.group.is_none() { + return; + } + let (default_type, _) = self.entry.name.default_types(); + let default_type = default_type.unwrap_vcard(); + + for param in std::mem::take(&mut self.entry.params) { + let value = match ¶m.name { + VCardParameterName::Value => { + if let Some(v) = param + .value + .into_value_type() + .filter(|v| !v.is_iana_and(|v| v == &default_type)) + { + *value_type = Some(v); + } + continue; + } + + VCardParameterName::Created => { + let Some(IanaType::Iana(timestamp)) = param.value.into_timestamp() else { + continue; + }; + + Value::Element(JSContactValue::Timestamp(timestamp)) + } + VCardParameterName::Jscomps => { + let Some(v) = param.value.into_jscomps() else { + continue; + }; + let mut jscomps = String::new(); + let _ = write_jscomps(&mut jscomps, &v); + Value::Str(jscomps.into()) + } + VCardParameterName::Other(name) if name.eq_ignore_ascii_case("group") => { + // The GROUP parameter (Section 7.1 of [RFC7095]) does not convert to JSContact. + // It exclusively is for use in jCard and MUST NOT be set in a vCard. + continue; + } + VCardParameterName::Group => { + // The GROUP parameter (Section 7.1 of [RFC7095]) does not convert to JSContact. + // It exclusively is for use in jCard and MUST NOT be set in a vCard. + continue; + } + _ => Value::Str(param.value.into_text()), + }; + + params.0.entry(param.name).or_default().push(value); + } + + if let Some(group) = self.entry.group.take() { + params + .0 + .insert(VCardParameterName::Group, vec![group.into()]); + } + } + + pub(super) fn set_converted_to(&mut self, converted_to: &[&str]) { + self.converted_to = Some(JsonPointer::>::encode(converted_to)); + } + + pub(super) fn set_map_name(&mut self) { + self.map_name = true; + } + + pub(super) fn to_text( + &mut self, + ) -> Option, JSContactValue>> { + self.to_string().map(Into::into) + } + + pub(super) fn to_string(&mut self) -> Option> { + let mut values = std::mem::take(&mut self.entry.values).into_iter(); + match values.next()? { + VCardValue::Text(v) => Some(Cow::Owned(v)), + VCardValue::Component(v) => Some(Cow::Owned(v.join(","))), + VCardValue::Binary(data) => Some(Cow::Owned(data.to_unwrapped_string())), + VCardValue::Sex(v) => Some(v.as_str().into()), + VCardValue::GramGender(v) => Some(v.as_str().into()), + VCardValue::Kind(v) => Some(v.as_str().into()), + other => { + self.entry.values.push(other); + self.entry.values.extend(values); + None + } + } + } + + pub(super) fn text_parts_borrowed(&self) -> impl Iterator + '_ { + self.entry.values.iter().filter_map(|value| value.as_text()) + } + + pub(super) fn to_kind( + &mut self, + ) -> Option, JSContactValue>> { + let mut values = std::mem::take(&mut self.entry.values).into_iter(); + match values.next()? { + VCardValue::Kind(v) => Value::Element(JSContactValue::Kind(match v { + VCardKind::Application => JSContactKind::Application, + VCardKind::Device => JSContactKind::Device, + VCardKind::Group => JSContactKind::Group, + VCardKind::Individual => JSContactKind::Individual, + VCardKind::Location => JSContactKind::Location, + VCardKind::Org => JSContactKind::Org, + })) + .into(), + VCardValue::Text(v) => Value::Str(v.into()).into(), + VCardValue::Component(v) => Value::Str(v.join(",").into()).into(), + other => { + self.entry.values.push(other); + self.entry.values.extend(values); + None + } + } + } + + pub(super) fn to_gram_gender( + &mut self, + ) -> Option, JSContactValue>> { + let mut values = std::mem::take(&mut self.entry.values).into_iter(); + match values.next()? { + VCardValue::GramGender(v) => { + Value::Element(JSContactValue::GrammaticalGender(match v { + VCardGramGender::Animate => JSContactGrammaticalGender::Animate, + VCardGramGender::Common => JSContactGrammaticalGender::Common, + VCardGramGender::Feminine => JSContactGrammaticalGender::Feminine, + VCardGramGender::Inanimate => JSContactGrammaticalGender::Inanimate, + VCardGramGender::Masculine => JSContactGrammaticalGender::Masculine, + VCardGramGender::Neuter => JSContactGrammaticalGender::Neuter, + })) + .into() + } + VCardValue::Text(v) => Value::Str(v.into()).into(), + VCardValue::Component(v) => Value::Str(v.join(",").into()).into(), + other => { + self.entry.values.push(other); + self.entry.values.extend(values); + None + } + } + } + + pub(super) fn to_timestamp( + &mut self, + ) -> Option, JSContactValue>> { + if let Some(VCardValue::PartialDateTime(dt)) = self.entry.values.first() + && let Some(timestamp) = dt.to_timestamp() + { + return Value::Element(JSContactValue::Timestamp(timestamp)).into(); + } + + None + } + + pub(super) fn to_tz( + &mut self, + ) -> Option, JSContactValue>> { + let mut values = std::mem::take(&mut self.entry.values).into_iter(); + match values.next()? { + VCardValue::PartialDateTime(v) if v.has_zone() => { + let hour = v.tz_hour.unwrap_or_default(); + + Value::Str( + if hour != 0 { + format!("Etc/GMT{}{}", if v.tz_minus { "+" } else { "-" }, hour) + } else { + "Etc/UTC".to_string() + } + .into(), + ) + .into() + } + VCardValue::Text(v) => Value::Str(v.into()).into(), + VCardValue::Component(v) => Value::Str(v.join(",").into()).into(), + other => { + self.entry.values.push(other); + self.entry.values.extend(values); + None + } + } + } + + pub(super) fn to_anniversary( + &mut self, + ) -> Option, JSContactValue>> { + if let Some(VCardValue::PartialDateTime(dt)) = self.entry.values.first() { + if let Some(timestamp) = dt.to_timestamp() { + Some(Value::Object(Map::from(vec![ + ( + Key::Property(JSContactProperty::Type), + Value::Element(JSContactValue::Type(JSContactType::Timestamp)), + ), + ( + Key::Property(JSContactProperty::Utc), + Value::Element(JSContactValue::Timestamp(timestamp)), + ), + ]))) + } else if dt.day.is_some() || dt.month.is_some() || dt.year.is_some() { + let mut props = Map::from(vec![( + Key::Property(JSContactProperty::Type), + Value::Element(JSContactValue::Type(JSContactType::PartialDate)), + )]); + if let Some(year) = dt.year { + props.insert_unchecked( + Key::Property(JSContactProperty::Year), + Value::Number((year as u64).into()), + ); + } + if let Some(month) = dt.month { + props.insert_unchecked( + Key::Property(JSContactProperty::Month), + Value::Number((month as u64).into()), + ); + } + if let Some(day) = dt.day { + props.insert_unchecked( + Key::Property(JSContactProperty::Day), + Value::Number((day as u64).into()), + ); + } + + Some(Value::Object(props)) + } else { + None + } + } else { + None + } + } + + pub(super) fn non_default_language(&self, default_language: Option<&str>) -> Option<&str> { + self.entry + .language() + .filter(|&lang| Some(lang) != default_language) + } +} diff --git a/vendor/calcard/src/jscontact/import/mod.rs b/vendor/calcard/src/jscontact/import/mod.rs new file mode 100644 index 00000000..949505e6 --- /dev/null +++ b/vendor/calcard/src/jscontact/import/mod.rs @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{CalendarScale, IanaType}, + jscontact::{JSContactId, JSContactProperty, JSContactValue}, + vcard::{ + Jscomp, VCardEntry, VCardLevel, VCardParameterName, VCardPhonetic, VCardProperty, VCardType, + }, +}; +use ahash::AHashMap; +use jmap_tools::{JsonPointer, Key, Map, Value}; +use std::collections::HashMap; + +pub mod convert; +pub mod entry; +pub mod params; +pub mod props; + +#[derive(Debug, Clone, Copy)] +pub struct ConversionOptions { + pub include_vcard_parameters: bool, +} + +#[allow(clippy::type_complexity)] +struct State +where + I: JSContactId, + B: JSContactId, +{ + entries: AHashMap< + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + >, + vcard_converted_properties: AHashMap>, + vcard_properties: Vec, JSContactValue>>, + patch_objects: Vec<( + JsonPointer>, + Value<'static, JSContactProperty, JSContactValue>, + )>, + localizations: HashMap< + String, + Vec<( + String, + Value<'static, JSContactProperty, JSContactValue>, + )>, + >, + default_language: Option, + prop_ids: Vec>, + name_alt_id: Option, + has_fn: bool, + has_fn_localization: bool, + has_n: bool, + has_n_localization: bool, + has_gram_gender: bool, + include_vcard_converted: bool, +} + +#[derive(Debug, Clone)] +struct EntryState { + entry: VCardEntry, + converted_to: Option, + map_name: bool, +} + +struct PropIdKey +where + I: JSContactId, +{ + prop: VCardProperty, + prop_js: JSContactProperty, + group: Option, + alt_id: Option, + prop_id: String, +} + +#[derive(Debug, Default)] +struct VCardConvertedProperty +where + I: JSContactId, + B: JSContactId, +{ + name: Option, + params: VCardParams, +} + +#[derive(Debug, Default)] +#[allow(clippy::type_complexity)] +struct VCardParams( + AHashMap, JSContactValue>>>, +) +where + I: JSContactId, + B: JSContactId; + +#[derive(Default)] +struct ExtractedParams { + language: Option, + pref: Option, + author: Option, + author_name: Option, + phonetic_system: Option>, + phonetic_script: Option, + media_type: Option, + calscale: Option>, + sort_as: Option, + prop_id: Option, + alt_id: Option, + types: Vec>, + geo: Option, + tz: Option, + index: Option, + level: Option>, + country_code: Option, + created: Option, + label: Option, + service_type: Option, + username: Option, + jscomps: Vec, +} + +trait GetObjectOrCreate +where + I: JSContactId, + B: JSContactId, +{ + fn get_mut_object_or_insert( + &mut self, + key: JSContactProperty, + ) -> &mut Map<'static, JSContactProperty, JSContactValue>; +} + +impl GetObjectOrCreate + for AHashMap< + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + > +where + I: JSContactId, + B: JSContactId, +{ + #[inline] + fn get_mut_object_or_insert( + &mut self, + key: JSContactProperty, + ) -> &mut Map<'static, JSContactProperty, JSContactValue> { + self.entry(Key::Property(key)) + .or_insert_with(|| Value::Object(Map::from(Vec::new()))) + .as_object_mut() + .unwrap() + } +} + +impl Default for ConversionOptions { + fn default() -> Self { + Self { + include_vcard_parameters: true, + } + } +} + +impl ConversionOptions { + pub fn include_vcard_parameters(mut self, include: bool) -> Self { + self.include_vcard_parameters = include; + self + } +} diff --git a/vendor/calcard/src/jscontact/import/params.rs b/vendor/calcard/src/jscontact/import/params.rs new file mode 100644 index 00000000..e80e70ed --- /dev/null +++ b/vendor/calcard/src/jscontact/import/params.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::IanaType, + jscontact::{ + JSContactId, JSContactKind, JSContactLevel, JSContactPhoneticSystem, JSContactProperty, + JSContactValue, + import::{ExtractedParams, VCardParams}, + }, + vcard::{VCardLevel, VCardPhonetic, VCardProperty, VCardType}, +}; +use jmap_tools::{Key, Map, Value}; + +impl ExtractedParams { + pub(super) fn prop_id(&mut self) -> Option { + self.prop_id.take() + } + + pub(super) fn alt_id(&mut self) -> Option { + self.alt_id.take() + } + + pub(super) fn language(&mut self) -> Option { + self.language.take() + } + + pub(super) fn types(&mut self) -> Vec> { + std::mem::take(&mut self.types) + } + + #[allow(clippy::type_complexity)] + pub(super) fn into_iter( + mut self, + property: &VCardProperty, + ) -> impl Iterator< + Item = ( + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + ), + > { + let mut contexts: Option< + Vec<( + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + )>, + > = None; + let mut features: Option< + Vec<( + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + )>, + > = None; + + if !self.types.is_empty() { + let is_phone = matches!(property, VCardProperty::Tel); + + for typ in std::mem::take(&mut self.types) { + if is_phone + && matches!( + typ, + IanaType::Iana( + VCardType::Fax + | VCardType::Cell + | VCardType::Video + | VCardType::Pager + | VCardType::Textphone + | VCardType::MainNumber + | VCardType::Text + | VCardType::Voice + ) + ) + { + features.get_or_insert_default() + } else { + contexts.get_or_insert_default() + } + .push(( + match typ { + IanaType::Iana(VCardType::Home) => Key::Borrowed("private"), + IanaType::Iana(VCardType::Cell) => Key::Borrowed("mobile"), + _ => Key::Owned(typ.into_string().to_ascii_lowercase()), + }, + Value::Bool(true), + )); + } + } + + let author = if self.author.is_some() || self.author_name.is_some() { + Value::Object(Map::from_iter( + [ + self.author_name.map(|name| { + ( + Key::Property(JSContactProperty::Name), + Value::Str(name.into()), + ) + }), + self.author.map(|author| { + ( + Key::Property(JSContactProperty::Uri), + Value::Str(author.into()), + ) + }), + ] + .into_iter() + .flatten(), + )) + .into() + } else { + None + }; + + [ + ( + Key::Property(JSContactProperty::Contexts), + contexts.map(|v| Value::Object(Map::from(v))), + ), + ( + Key::Property(JSContactProperty::Features), + features.map(|v| Value::Object(Map::from(v))), + ), + ( + Key::Property(JSContactProperty::Language), + self.language.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::Pref), + self.pref.map(|v| Value::Number((v as u64).into())), + ), + (Key::Property(JSContactProperty::Author), author), + ( + Key::Property(JSContactProperty::MediaType), + self.media_type.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::PhoneticSystem), + self.phonetic_system.map(|v| match v { + IanaType::Iana(value) => { + Value::Element(JSContactValue::PhoneticSystem(match value { + VCardPhonetic::Ipa => JSContactPhoneticSystem::Ipa, + VCardPhonetic::Jyut => JSContactPhoneticSystem::Jyut, + VCardPhonetic::Piny => JSContactPhoneticSystem::Piny, + VCardPhonetic::Script => JSContactPhoneticSystem::Script, + })) + } + IanaType::Other(value) => Value::Str(value.into()), + }), + ), + ( + Key::Property(JSContactProperty::PhoneticScript), + self.phonetic_script.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::CalendarScale), + self.calscale.map(|value| match value { + IanaType::Iana(value) => Value::Element(JSContactValue::CalendarScale(value)), + IanaType::Other(value) => Value::Str(value.into()), + }), + ), + ( + Key::Property(JSContactProperty::SortAs), + self.sort_as.map(|sort_as| { + if matches!(property, VCardProperty::N) { + if let Some((surname, given)) = + sort_as.split_once(',').and_then(|(surname, given)| { + let surname = surname.trim(); + let given = given.trim(); + + if surname.is_empty() || given.is_empty() { + None + } else { + Some((surname.to_string(), given.to_string())) + } + }) + { + Value::Object(Map::from_iter(vec![ + ( + Key::Property(JSContactProperty::SortAsKind( + JSContactKind::Surname, + )), + Value::Str(surname.into()), + ), + ( + Key::Property(JSContactProperty::SortAsKind( + JSContactKind::Given, + )), + Value::Str(given.into()), + ), + ])) + } else { + Value::Object(Map::from_iter(vec![( + Key::Property(JSContactProperty::SortAsKind( + JSContactKind::Surname, + )), + Value::Str(sort_as.into()), + )])) + } + } else { + Value::Str(sort_as.into()) + } + }), + ), + ( + Key::Property(JSContactProperty::Coordinates), + self.geo.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::TimeZone), + self.tz.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::ListAs), + self.index.map(|v| Value::Number((v as u64).into())), + ), + ( + Key::Property(JSContactProperty::Level), + self.level.map(|value| match value { + IanaType::Iana(value) => Value::Element(JSContactValue::Level(match value { + VCardLevel::Beginner | VCardLevel::Low => JSContactLevel::Low, + VCardLevel::Average | VCardLevel::Medium => JSContactLevel::Medium, + VCardLevel::Expert | VCardLevel::High => JSContactLevel::High, + })), + IanaType::Other(value) => Value::Str(value.into()), + }), + ), + ( + Key::Property(JSContactProperty::CountryCode), + self.country_code.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::Created), + self.created + .map(|v| Value::Element(JSContactValue::Timestamp(v))), + ), + ( + Key::Property(if matches!(property, VCardProperty::Adr) { + JSContactProperty::Full + } else { + JSContactProperty::Label + }), + self.label.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::Service), + self.service_type.map(Into::into).map(Value::Str), + ), + ( + Key::Property(JSContactProperty::User), + self.username.map(Into::into).map(Value::Str), + ), + ] + .into_iter() + .filter_map(|(k, v)| v.map(|v| (k, v))) + } +} + +impl VCardParams { + pub(super) fn into_jscontact_value( + self, + ) -> Option, JSContactValue>> { + if !self.0.is_empty() { + let mut obj = Map::from(Vec::with_capacity(self.0.len())); + + for (param, value) in self.0 { + let value = if value.len() > 1 { + Value::Array(value) + } else { + value.into_iter().next().unwrap() + }; + obj.insert_unchecked(Key::Owned(param.into_string().to_ascii_lowercase()), value); + } + Some(obj) + } else { + None + } + } +} diff --git a/vendor/calcard/src/jscontact/import/props.rs b/vendor/calcard/src/jscontact/import/props.rs new file mode 100644 index 00000000..66fa9b86 --- /dev/null +++ b/vendor/calcard/src/jscontact/import/props.rs @@ -0,0 +1,655 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{IanaString, IanaType}, + jscontact::{ + JSContact, JSContactId, JSContactProperty, JSContactType, JSContactValue, + import::{ + EntryState, ExtractedParams, PropIdKey, State, VCardConvertedProperty, VCardParams, + }, + }, + vcard::{ + VCard, VCardEntry, VCardParameter, VCardParameterName, VCardProperty, VCardValue, + VCardValueType, + }, +}; +use ahash::AHashMap; +use jmap_tools::{JsonPointerHandler, Key, Map, Property, Value}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet, hash_map::Entry}, +}; + +impl State +where + I: JSContactId, + B: JSContactId, +{ + pub(super) fn new(vcard: &mut VCard, include_vcard_converted: bool) -> Self { + let mut entries = AHashMap::with_capacity(vcard.entries.len()); + + entries.extend([ + ( + Key::Property(JSContactProperty::Type), + Value::Element(JSContactValue::Type(JSContactType::Card)), + ), + ( + Key::Property(JSContactProperty::Version), + Value::Str("1.0".into()), + ), + ]); + + // Find the default language and the most "popular" alt ids + let mut default_language = None; + let mut language_map: HashMap = HashMap::new(); + let mut language_count = 0; + let mut language_first_found = None; + let mut langful_names: HashSet<&VCardProperty> = HashSet::new(); + let mut alt_ids: AHashMap<(&VCardProperty, &str), usize> = AHashMap::new(); + for entry in &vcard.entries { + if let Some(lang) = entry.language() { + *language_map.entry(lang.to_ascii_lowercase()).or_default() += 1; + if language_first_found.is_none() { + language_first_found = Some(lang); + } + language_count += 1; + langful_names.insert(&entry.name); + } + + match &entry.name { + VCardProperty::Language => { + if let Some(VCardValue::Text(lang)) = entry.values.first() { + default_language = Some(lang.to_ascii_lowercase()); + } + } + VCardProperty::N | VCardProperty::Adr => { + if let Some(alt_id) = entry.alt_id() { + *alt_ids.entry((&entry.name, alt_id)).or_default() += 1; + } + } + _ => (), + } + } + + // Find the alt ids with the highest count + let mut name_alt_id = None; + let mut name_alt_id_count = 0; + for (&(prop, alt_id), &count) in &alt_ids { + match prop { + VCardProperty::N if count > name_alt_id_count => { + name_alt_id = Some(alt_id.to_string()); + name_alt_id_count = count; + } + _ => (), + } + } + + // Find the dominant language + if default_language.is_none() + && language_count > 1 + && let Some((_, &min_count)) = language_map.iter().min_by_key(|&(_, count)| count) + && let Some((mut lang, max_count)) = + language_map.into_iter().max_by_key(|&(_, count)| count) + && max_count + > vcard + .entries + .iter() + .filter(|entry| { + entry.language().is_none() && langful_names.contains(&entry.name) + }) + .count() + { + if max_count == min_count { + lang = language_first_found.unwrap().to_ascii_lowercase(); + } + + let lang = lang.to_ascii_lowercase(); + default_language = Some(lang.clone()); + vcard + .entries + .push(VCardEntry::new(VCardProperty::Language).with_value(lang)); + } + + // Move entries without a language to the top + vcard.entries.sort_unstable_by_key(|entry| { + let lang = entry.language(); + let weight = u32::from(lang.is_some() && default_language.as_deref() != lang); + + match &entry.name { + VCardProperty::Birthplace | VCardProperty::Deathplace | VCardProperty::Role => { + weight + 2 + } + VCardProperty::Other(name) if name.eq_ignore_ascii_case("X-ABLabel") => weight + 3, + _ => weight, + } + }); + + Self { + entries, + default_language, + localizations: Default::default(), + prop_ids: Default::default(), + vcard_converted_properties: Default::default(), + vcard_properties: Default::default(), + patch_objects: Default::default(), + name_alt_id, + has_fn: false, + has_n: false, + has_n_localization: false, + has_fn_localization: false, + has_gram_gender: false, + include_vcard_converted, + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn map_named_entry( + &mut self, + entry: &mut EntryState, + extract: &[VCardParameterName], + top_property_name: JSContactProperty, + value_property_name: JSContactProperty, + extra_properties: impl IntoIterator< + Item = ( + Key<'static, JSContactProperty>, + Value<'static, JSContactProperty, JSContactValue>, + ), + >, + ) { + let value = if !matches!( + entry.entry.name, + VCardProperty::Anniversary | VCardProperty::Bday | VCardProperty::Deathdate + ) { + entry.to_text() + } else { + entry.to_anniversary() + }; + + if let Some(value) = value { + let mut params = self.extract_params(&mut entry.entry.params, extract); + let prop_id = params.prop_id(); + let alt_id = params.alt_id(); + let sub_property = top_property_name.sub_property(); + + // Locate the address to patch or reset language + if let Some(language) = params.language() { + if let Some(patch) = prop_id + .as_deref() + .filter(|prop_id| self.has_prop_id(&entry.entry.name, prop_id)) + .or_else(|| { + self.find_prop_id( + &entry.entry.name, + entry.entry.group.as_deref(), + alt_id.as_deref(), + ) + }) + .map(|prop_id| { + if let Some(sub_property) = sub_property.as_ref() { + format!( + "{}/{}/{}/{}", + top_property_name.to_cow().as_ref(), + sub_property.to_cow().as_ref(), + prop_id, + value_property_name.to_cow().as_ref() + ) + } else { + format!( + "{}/{}/{}", + top_property_name.to_cow().as_ref(), + prop_id, + value_property_name.to_cow().as_ref() + ) + } + }) + { + entry.set_converted_to::(&[ + JSContactProperty::Localizations::.to_cow().as_ref(), + language.as_str(), + patch.as_str(), + ]); + + let localizations = self.localizations.entry(language).or_default(); + let mut base_path = None; + + for (prop, value) in params.into_iter(&entry.entry.name) { + let base_path = base_path.get_or_insert_with(|| { + patch.rsplit_once('/').map(|(base, _)| base).unwrap() + }); + localizations.push((format!("{}/{}", base_path, prop.to_string()), value)); + } + + localizations.push((patch, value)); + return; + } else { + entry.entry.params.push(VCardParameter::language(language)); + } + } + + let mut entries = self.get_mut_object_or_insert(top_property_name.clone()); + if let Some(sub_property) = sub_property.clone() { + entries = entries + .insert_or_get_mut(sub_property, Value::Object(Map::from(vec![]))) + .as_object_mut() + .unwrap(); + } + + let mut obj = vec![(Key::Property(value_property_name.clone()), value)]; + obj.extend(extra_properties); + obj.extend(params.into_iter(&entry.entry.name)); + let prop_id = entries.insert_named(prop_id, Value::Object(Map::from(obj))); + + if let Some(sub_property) = sub_property { + entry.set_converted_to::(&[ + top_property_name.to_cow().as_ref(), + sub_property.to_cow().as_ref(), + prop_id.as_str(), + value_property_name.to_cow().as_ref(), + ]); + } else { + entry.set_converted_to::(&[ + top_property_name.to_cow().as_ref(), + prop_id.as_str(), + value_property_name.to_cow().as_ref(), + ]); + } + + self.track_prop(&entry.entry, top_property_name, alt_id, prop_id); + } + } + + pub(super) fn extract_params( + &self, + params: &mut Vec, + extract: &[VCardParameterName], + ) -> ExtractedParams { + let mut p = ExtractedParams::default(); + + for param in std::mem::take(params) { + match ¶m.name { + VCardParameterName::Language => { + let v = param.value.into_text().to_ascii_lowercase(); + if p.language.is_none() + && self.default_language.as_ref().is_none_or(|lang| lang != &v) + && extract.contains(&VCardParameterName::Language) + { + p.language = Some(v); + } else { + params.push(VCardParameter::language(v)); + } + } + VCardParameterName::Pref + if p.pref.is_none() && extract.contains(&VCardParameterName::Pref) => + { + p.pref = param.value.as_integer().and_then(|v| v.into_iana()); + } + VCardParameterName::Author + if p.author.is_none() && extract.contains(&VCardParameterName::Author) => + { + p.author = Some(param.value.into_text().into_owned()); + } + VCardParameterName::AuthorName + if p.author_name.is_none() + && extract.contains(&VCardParameterName::AuthorName) => + { + p.author_name = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Mediatype + if p.media_type.is_none() + && extract.contains(&VCardParameterName::Mediatype) => + { + p.media_type = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Calscale + if p.calscale.is_none() && extract.contains(&VCardParameterName::Calscale) => + { + p.calscale = param.value.into_calscale(); + } + VCardParameterName::SortAs + if p.sort_as.is_none() && extract.contains(&VCardParameterName::SortAs) => + { + p.sort_as = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Geo + if p.geo.is_none() && extract.contains(&VCardParameterName::Geo) => + { + p.geo = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Tz + if p.tz.is_none() && extract.contains(&VCardParameterName::Tz) => + { + p.tz = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Index + if p.index.is_none() && extract.contains(&VCardParameterName::Index) => + { + p.index = param.value.as_integer().and_then(|v| v.into_iana()); + } + VCardParameterName::Level + if p.level.is_none() && extract.contains(&VCardParameterName::Level) => + { + p.level = param.value.into_level(); + } + VCardParameterName::Cc + if p.country_code.is_none() && extract.contains(&VCardParameterName::Cc) => + { + p.country_code = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Created + if p.created.is_none() && extract.contains(&VCardParameterName::Created) => + { + p.created = param.value.into_timestamp().and_then(|v| v.into_iana()); + } + VCardParameterName::Label + if p.label.is_none() && extract.contains(&VCardParameterName::Label) => + { + p.label = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Phonetic + if p.phonetic_system.is_none() + && extract.contains(&VCardParameterName::Phonetic) => + { + p.phonetic_system = param.value.into_phonetic(); + } + VCardParameterName::Script + if p.phonetic_script.is_none() + && extract.contains(&VCardParameterName::Script) => + { + p.phonetic_script = Some(param.value.into_text().into_owned()); + } + VCardParameterName::ServiceType + if p.service_type.is_none() + && extract.contains(&VCardParameterName::ServiceType) => + { + p.service_type = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Username + if p.username.is_none() && extract.contains(&VCardParameterName::Username) => + { + p.username = Some(param.value.into_text().into_owned()); + } + VCardParameterName::PropId + if p.prop_id.is_none() && extract.contains(&VCardParameterName::PropId) => + { + p.prop_id = Some(param.value.into_text().into_owned()); + } + VCardParameterName::Altid if p.alt_id.is_none() => { + p.alt_id = param.value.as_text().map(|v| v.to_string()); + params.push(param); + } + VCardParameterName::Type if extract.contains(&VCardParameterName::Type) => { + if let Some(typ) = param.value.into_type() { + if p.types.is_empty() { + p.types = vec![typ]; + } else { + p.types.push(typ); + } + } + } + VCardParameterName::Jscomps if extract.contains(&VCardParameterName::Jscomps) => { + if let Some(jscomps) = param.value.into_jscomps() { + p.jscomps = jscomps; + } + } + _ => { + params.push(param); + } + } + } + + p + } + + #[inline] + pub(super) fn get_mut_object_or_insert( + &mut self, + key: JSContactProperty, + ) -> &mut Map<'static, JSContactProperty, JSContactValue> { + self.entries + .entry(Key::Property(key)) + .or_insert_with(|| Value::Object(Map::from(Vec::new()))) + .as_object_mut() + .unwrap() + } + + #[inline] + pub(super) fn has_property(&self, key: JSContactProperty) -> bool { + self.entries.contains_key(&Key::Property(key)) + } + + pub(super) fn add_conversion_props(&mut self, mut entry: EntryState) { + if self.include_vcard_converted { + if let Some(converted_to) = entry.converted_to.take() { + if entry.map_name || !entry.entry.params.is_empty() || entry.entry.group.is_some() { + let mut value_type = None; + + match self.vcard_converted_properties.entry(converted_to) { + Entry::Occupied(mut conv_prop) => { + entry.jcal_parameters(&mut conv_prop.get_mut().params, &mut value_type); + } + Entry::Vacant(conv_prop) => { + let mut params = VCardParams::default(); + entry.jcal_parameters(&mut params, &mut value_type); + if let Some(value_type) = value_type { + params.0.insert( + VCardParameterName::Value, + vec![Value::Str(value_type.into_string())], + ); + } + if !params.0.is_empty() || entry.map_name { + conv_prop.insert(VCardConvertedProperty { + name: if entry.map_name { + Some(entry.entry.name) + } else { + None + }, + params, + }); + } + } + } + } + } else { + let mut value_type = None; + let mut params = VCardParams::default(); + + entry.jcal_parameters(&mut params, &mut value_type); + + let values = if entry.entry.values.len() == 1 { + entry + .entry + .values + .into_iter() + .next() + .unwrap() + .into_jscontact_value(value_type.as_ref()) + } else { + let mut values = Vec::with_capacity(entry.entry.values.len()); + for value in entry.entry.values { + values.push(value.into_jscontact_value(value_type.as_ref())); + } + Value::Array(values) + }; + self.vcard_properties.push(Value::Array(vec![ + Value::Str(entry.entry.name.as_str().to_ascii_lowercase().into()), + Value::Object( + params + .into_jscontact_value() + .unwrap_or(Map::from(Vec::new())), + ), + Value::Str( + value_type + .map(|v| v.into_string()) + .unwrap_or(Cow::Borrowed("unknown")), + ), + values, + ])); + } + } + } + + #[inline] + pub(super) fn track_prop( + &mut self, + entry: &VCardEntry, + prop_js: JSContactProperty, + alt_id: Option, + prop_id: String, + ) { + self.prop_ids.push(PropIdKey { + prop_id, + prop_js, + prop: entry.name.clone(), + group: entry.group.clone(), + alt_id, + }); + } + + pub(super) fn find_prop_id( + &self, + prop: &VCardProperty, + group: Option<&str>, + alt_id: Option<&str>, + ) -> Option<&str> { + self.prop_ids + .iter() + .find(|p| { + p.prop == *prop && p.group.as_deref() == group && p.alt_id.as_deref() == alt_id + }) + .map(|p| p.prop_id.as_str()) + } + + pub(super) fn find_entry_by_group(&self, group: Option<&str>) -> Option<&PropIdKey> { + self.prop_ids.iter().find(|p| p.group.as_deref() == group) + } + + pub(super) fn has_prop_id(&self, prop: &VCardProperty, prop_id: &str) -> bool { + self.prop_ids + .iter() + .any(|p| p.prop == *prop && p.prop_id == prop_id) + } + + pub(super) fn into_jscontact(mut self) -> JSContact<'static, I, B> { + if !self.localizations.is_empty() { + self.entries.insert( + Key::Property(JSContactProperty::Localizations), + Value::Object( + self.localizations + .into_iter() + .map(|(lang, locals)| { + ( + Key::Owned(lang), + Value::Object( + locals + .into_iter() + .map(|(key, value)| (Key::Owned(key), value)) + .collect(), + ), + ) + }) + .collect(), + ), + ); + } + + let mut vcard_obj = Map::from(Vec::new()); + if !self.vcard_converted_properties.is_empty() { + let mut converted_properties = + Map::from(Vec::with_capacity(self.vcard_converted_properties.len())); + + for (converted_to, props) in self.vcard_converted_properties { + let mut obj = Map::from(Vec::with_capacity(2)); + if let Some(params) = props.params.into_jscontact_value() { + obj.insert( + Key::Property(JSContactProperty::Parameters), + Value::Object(params), + ); + } + if let Some(name) = props.name { + obj.insert( + Key::Property(JSContactProperty::Name), + Value::Str(name.as_str().to_ascii_lowercase().into()), + ); + } + + converted_properties.insert_unchecked(Key::Owned(converted_to), Value::Object(obj)); + } + + vcard_obj.insert_unchecked( + Key::Property(JSContactProperty::ConvertedProperties), + Value::Object(converted_properties), + ); + } + + if !self.vcard_properties.is_empty() { + vcard_obj.insert_unchecked( + Key::Property(JSContactProperty::Properties), + Value::Array(self.vcard_properties), + ); + } + + if !vcard_obj.is_empty() { + self.entries.insert( + Key::Property(JSContactProperty::VCard), + Value::Object(vcard_obj), + ); + } + + let mut obj = Value::Object(self.entries.into_iter().collect()); + if !self.patch_objects.is_empty() { + for (ptr, patch) in self.patch_objects { + obj.patch_jptr(ptr.iter(), patch); + } + } + + JSContact(obj) + } +} + +impl JSContactProperty +where + I: JSContactId, +{ + pub(super) fn sub_property(&self) -> Option> { + match self { + JSContactProperty::SpeakToAs => Some(JSContactProperty::Pronouns), + _ => None, + } + } +} + +impl VCardValue { + pub(super) fn into_jscontact_value( + self, + value_type: Option<&IanaType>, + ) -> Value<'static, JSContactProperty, JSContactValue> { + match self { + VCardValue::Text(v) => Value::Str(v.into()), + VCardValue::Component(v) => Value::Str(v.join(",").into()), + VCardValue::Integer(v) => Value::Number(v.into()), + VCardValue::Float(v) => Value::Number(v.into()), + VCardValue::Boolean(v) => Value::Bool(v), + VCardValue::PartialDateTime(v) => { + let mut out = String::new(); + let _ = v.format_as_vcard( + &mut out, + value_type + .and_then(|v| v.iana()) + .unwrap_or(if v.has_date() && v.has_time() { + &VCardValueType::Timestamp + } else { + &VCardValueType::DateAndOrTime + }), + ); + Value::Str(out.into()) + } + VCardValue::Binary(v) => Value::Str(v.to_unwrapped_string().into()), + VCardValue::Sex(v) => Value::Str(v.as_str().into()), + VCardValue::GramGender(v) => Value::Str(v.as_str().into()), + VCardValue::Kind(v) => Value::Str(v.as_str().into()), + } + } +} diff --git a/vendor/calcard/src/jscontact/mod.rs b/vendor/calcard/src/jscontact/mod.rs new file mode 100644 index 00000000..52fee34d --- /dev/null +++ b/vendor/calcard/src/jscontact/mod.rs @@ -0,0 +1,707 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::common::CalendarScale; +use jmap_tools::{JsonPointer, Value}; +use serde::Serialize; +use std::{ + borrow::Cow, + fmt::{Debug, Display}, + hash::Hash, + str::FromStr, +}; + +pub mod export; +pub mod import; +pub mod parser; +pub mod types; + +#[cfg(test)] +thread_local! { + static FN_DERIVATION_DISABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn fn_derivation_disabled() -> bool { + FN_DERIVATION_DISABLED.with(|flag| flag.get()) +} + +#[cfg(test)] +pub(crate) fn set_fn_derivation_disabled(disabled: bool) { + FN_DERIVATION_DISABLED.with(|flag| flag.set(disabled)); +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[repr(transparent)] +pub struct JSContact<'x, I, B>(pub Value<'x, JSContactProperty, JSContactValue>) +where + I: JSContactId, + B: JSContactId; + +pub trait JSContactId: + FromStr + Sized + Serialize + Display + Clone + Eq + Hash + Ord + Debug + Default +{ +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactValue { + Id(I), + BlobId(B), + IdReference(String), + Timestamp(i64), + Type(JSContactType), + GrammaticalGender(JSContactGrammaticalGender), + Kind(JSContactKind), + Level(JSContactLevel), + Relation(JSContactRelation), + PhoneticSystem(JSContactPhoneticSystem), + CalendarScale(CalendarScale), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactProperty { + Type, + Address, + AddressBookIds, + Addresses, + Anniversaries, + Author, + BlobId, + Calendars, + CalendarScale, + Components, + Contexts, + ConvertedProperties, + Coordinates, + CountryCode, + Created, + CryptoKeys, + Date, + Day, + DefaultSeparator, + Directories, + Emails, + Extra, + Features, + Full, + GrammaticalGender, + Id, + IsOrdered, + Keywords, + Kind, + Label, + Language, + Level, + Links, + ListAs, + Localizations, + Media, + MediaType, + Members, + Month, + Name, + Nicknames, + Note, + Notes, + Number, + OnlineServices, + OrganizationId, + Organizations, + Parameters, + PersonalInfo, + Phones, + Phonetic, + PhoneticScript, + PhoneticSystem, + Place, + Pref, + PreferredLanguages, + ProdId, + Pronouns, + Properties, + RelatedTo, + Relation, + SchedulingAddresses, + Service, + SortAs, + SpeakToAs, + TimeZone, + Titles, + Uid, + Units, + Updated, + Uri, + User, + Utc, + VCard, + Value, + Version, + Year, + IdValue(I), + IdReference(String), + Context(Context), + Feature(Feature), + SortAsKind(JSContactKind), + Pointer(JsonPointer>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactType { + Address, + AddressComponent, + Anniversary, + Author, + Boolean, + Calendar, + Card, + CryptoKey, + Directory, + EmailAddress, + Id, + Int, + JCardProp, + LanguagePref, + Link, + Media, + Name, + NameComponent, + Nickname, + Note, + Number, + OnlineService, + Organization, + OrgUnit, + PartialDate, + PatchObject, + PersonalInfo, + Phone, + Pronouns, + Relation, + Resource, + SchedulingAddress, + SpeakToAs, + String, + Timestamp, + Title, + UnsignedInt, + UTCDateTime, +} + +impl FromStr for JSContactType { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactType, + "Address" => JSContactType::Address, + "AddressComponent" => JSContactType::AddressComponent, + "Anniversary" => JSContactType::Anniversary, + "Author" => JSContactType::Author, + "Boolean" => JSContactType::Boolean, + "Calendar" => JSContactType::Calendar, + "Card" => JSContactType::Card, + "CryptoKey" => JSContactType::CryptoKey, + "Directory" => JSContactType::Directory, + "EmailAddress" => JSContactType::EmailAddress, + "Id" => JSContactType::Id, + "Int" => JSContactType::Int, + "JCardProp" => JSContactType::JCardProp, + "LanguagePref" => JSContactType::LanguagePref, + "Link" => JSContactType::Link, + "Media" => JSContactType::Media, + "Name" => JSContactType::Name, + "NameComponent" => JSContactType::NameComponent, + "Nickname" => JSContactType::Nickname, + "Note" => JSContactType::Note, + "Number" => JSContactType::Number, + "OnlineService" => JSContactType::OnlineService, + "Organization" => JSContactType::Organization, + "OrgUnit" => JSContactType::OrgUnit, + "PartialDate" => JSContactType::PartialDate, + "PatchObject" => JSContactType::PatchObject, + "PersonalInfo" => JSContactType::PersonalInfo, + "Phone" => JSContactType::Phone, + "Pronouns" => JSContactType::Pronouns, + "Relation" => JSContactType::Relation, + "Resource" => JSContactType::Resource, + "SchedulingAddress" => JSContactType::SchedulingAddress, + "SpeakToAs" => JSContactType::SpeakToAs, + "String" => JSContactType::String, + "Timestamp" => JSContactType::Timestamp, + "Title" => JSContactType::Title, + "UnsignedInt" => JSContactType::UnsignedInt, + "UTCDateTime" => JSContactType::UTCDateTime, + ) + .copied() + .ok_or(()) + } +} + +impl JSContactType { + pub fn as_str(&self) -> &'static str { + match self { + JSContactType::Address => "Address", + JSContactType::AddressComponent => "AddressComponent", + JSContactType::Anniversary => "Anniversary", + JSContactType::Author => "Author", + JSContactType::Boolean => "Boolean", + JSContactType::Calendar => "Calendar", + JSContactType::Card => "Card", + JSContactType::CryptoKey => "CryptoKey", + JSContactType::Directory => "Directory", + JSContactType::EmailAddress => "EmailAddress", + JSContactType::Id => "Id", + JSContactType::Int => "Int", + JSContactType::JCardProp => "JCardProp", + JSContactType::LanguagePref => "LanguagePref", + JSContactType::Link => "Link", + JSContactType::Media => "Media", + JSContactType::Name => "Name", + JSContactType::NameComponent => "NameComponent", + JSContactType::Nickname => "Nickname", + JSContactType::Note => "Note", + JSContactType::Number => "Number", + JSContactType::OnlineService => "OnlineService", + JSContactType::Organization => "Organization", + JSContactType::OrgUnit => "OrgUnit", + JSContactType::PartialDate => "PartialDate", + JSContactType::PatchObject => "PatchObject", + JSContactType::PersonalInfo => "PersonalInfo", + JSContactType::Phone => "Phone", + JSContactType::Pronouns => "Pronouns", + JSContactType::Relation => "Relation", + JSContactType::Resource => "Resource", + JSContactType::SchedulingAddress => "SchedulingAddress", + JSContactType::SpeakToAs => "SpeakToAs", + JSContactType::String => "String", + JSContactType::Timestamp => "Timestamp", + JSContactType::Title => "Title", + JSContactType::UnsignedInt => "UnsignedInt", + JSContactType::UTCDateTime => "UTCDateTime", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Context { + Billing, + Delivery, + Private, + Work, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Feature { + Fax, + MainNumber, + Mobile, + Pager, + Text, + TextPhone, + Video, + Voice, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactGrammaticalGender { + Animate, + Common, + Feminine, + Inanimate, + Masculine, + Neuter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactKind { + Apartment, + Block, + Building, + Country, + Direction, + District, + Floor, + Landmark, + Locality, + Name, + Number, + Postcode, + PostOfficeBox, + Region, + Room, + Separator, + Subdistrict, + Birth, + Death, + Wedding, + Calendar, + FreeBusy, + Application, + Device, + Group, + Individual, + Location, + Org, + Directory, + Entry, + Contact, + Logo, + Photo, + Sound, + Credential, + Generation, + Given, + Given2, + Surname, + Surname2, + Title, + Expertise, + Hobby, + Interest, + Role, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactLevel { + High, + Low, + Medium, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactRelation { + Acquaintance, + Agent, + Child, + Colleague, + Contact, + CoResident, + CoWorker, + Crush, + Date, + Emergency, + Friend, + Kin, + Me, + Met, + Muse, + Neighbor, + Parent, + Sibling, + Spouse, + Sweetheart, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JSContactPhoneticSystem { + Ipa, + Jyut, + Piny, + Script, +} + +#[cfg(test)] +impl std::fmt::Display for JSContact<'_, I, B> +where + I: JSContactId, + B: JSContactId, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = serde_json::to_string_pretty(&self.0).map_err(|_| std::fmt::Error)?; + write!(f, "{}", s) + } +} + +impl JSContactId for T where + T: FromStr + Sized + Serialize + Display + Clone + Eq + Hash + Ord + Debug + Default +{ +} + +impl From for JSContactProperty { + fn from(id: I) -> Self { + JSContactProperty::IdValue(id) + } +} + +impl From for JSContactValue { + fn from(id: I) -> Self { + JSContactValue::Id(id) + } +} + +impl<'x, I: JSContactId, B: JSContactId> Default for JSContact<'x, I, B> { + fn default() -> Self { + Self(Value::Object(jmap_tools::Map::new())) + } +} + +impl Display for JSContactProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.to_string().as_ref()) + } +} + +#[cfg(test)] +mod tests { + use jmap_tools::{Key, Value}; + + use crate::{ + jscontact::{JSContact, JSContactProperty, JSContactValue}, + vcard::{VCard, VCardProperty}, + }; + + #[derive(Debug, Default)] + struct Test { + comment: String, + test: String, + expect: String, + roundtrip: String, + line_num: usize, + } + + #[test] + fn convert_jscontact() { + // Read all test files in the test directory + for entry in std::fs::read_dir("resources/jscontact").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let input = std::fs::read_to_string(&path).unwrap(); + super::set_fn_derivation_disabled( + path.file_name().and_then(|name| name.to_str()) != Some("003_names.txt"), + ); + let mut test = Test::default(); + let mut cur_command = ""; + let mut cur_value = &mut test.test; + + for (line_num, line) in input.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + if let Some(line) = line.strip_prefix("> ") { + let (command, comment) = line.split_once(' ').unwrap_or((line, "")); + + match (command, cur_command) { + ("test", _) => { + if !test.test.is_empty() { + test.run(); + } + test = Test::default(); + cur_value = &mut test.test; + cur_command = "test"; + test.comment = comment.to_string(); + test.line_num = line_num + 1; + } + ("convert", "test") => { + cur_command = "convert"; + cur_value = &mut test.expect; + } + ("convert", "convert") => { + cur_command = "convert"; + cur_value = &mut test.roundtrip; + } + _ => { + panic!( + "Unexpected command '{}' in file '{}' at line {}", + command, + path.display(), + line_num + 1 + ); + } + } + } else { + cur_value.push_str(line); + cur_value.push('\n'); + } + } + + if !test.test.is_empty() { + test.run(); + } + } + } + + impl Test { + fn run(mut self) { + if self.expect.is_empty() { + panic!( + "Test '{}' at line {} has no expected output", + self.comment, self.line_num + ); + } + + println!("Running test '{}' at line {}", self.comment, self.line_num); + + if is_jscontact(&self.test) { + fix_jscontact(&mut self.test); + fix_vcard(&mut self.expect); + let source = + sanitize_jscontact(parse_jscontact(&self.comment, self.line_num, &self.test)); + let expect = + sanitize_vcard(parse_vcard(&self.comment, self.line_num, &self.expect)); + let roundtrip = if !self.roundtrip.is_empty() { + fix_jscontact(&mut self.roundtrip); + sanitize_jscontact(parse_jscontact( + &self.comment, + self.line_num, + &self.roundtrip, + )) + } else { + source.clone() + }; + + let first_convert = sanitize_vcard(source.into_vcard().unwrap_or_else(|| { + panic!( + "Failed to convert JSContact to vCard: test {} on line {}: {}", + self.comment, self.line_num, self.test + ) + })); + if first_convert != expect { + let first_convert = + sanitize_vcard(VCard::parse(first_convert.to_string()).unwrap()); + + if first_convert != expect { + panic!( + "JSContact to vCard conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, expect, first_convert + ); + } + } + let roundtrip_convert = sanitize_jscontact(first_convert.into_jscontact()); + if roundtrip_convert != roundtrip { + let roundtrip_convert = roundtrip_convert.to_string(); + let roundtrip = roundtrip.to_string(); + + if roundtrip_convert != roundtrip { + panic!( + "vCard to JSContact conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, roundtrip, roundtrip_convert + ); + } + } + } else { + fix_vcard(&mut self.test); + fix_jscontact(&mut self.expect); + let source = sanitize_vcard(parse_vcard(&self.comment, self.line_num, &self.test)); + let expect = + sanitize_jscontact(parse_jscontact(&self.comment, self.line_num, &self.expect)); + let roundtrip = if !self.roundtrip.is_empty() { + fix_vcard(&mut self.roundtrip); + sanitize_vcard(parse_vcard(&self.comment, self.line_num, &self.roundtrip)) + } else { + source.clone() + }; + let first_convert = sanitize_jscontact(source.into_jscontact()); + if first_convert != expect { + let first_convert = first_convert.to_string(); + let expect = expect.to_string(); + + if first_convert != expect { + panic!( + "vCard to JSContact conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, expect, first_convert + ); + } + } + let roundtrip_convert = + sanitize_vcard(first_convert.into_vcard().unwrap_or_else(|| { + panic!( + "Failed to convert JSContact to vCard: test {} on line {}: {}", + self.comment, self.line_num, self.test + ) + })); + if roundtrip_convert != roundtrip { + let roundtrip_convert = + sanitize_vcard(VCard::parse(roundtrip_convert.to_string()).unwrap()); + if roundtrip_convert != roundtrip { + panic!( + "JSContact to vCard conversion failed: test {} on line {}, expected: {}, got: {}", + self.comment, self.line_num, roundtrip, roundtrip_convert + ); + } + } + } + } + } + + fn is_jscontact(s: &str) -> bool { + s.starts_with("{") || s.starts_with("\"") + } + + fn fix_vcard(s: &mut String) { + if !s.starts_with("BEGIN:VCARD") { + let mut v = "BEGIN:VCARD\nVERSION:4.0\n".to_string(); + v.push_str(s); + v.push_str("END:VCARD\n"); + *s = v; + } + } + + fn fix_jscontact(s: &mut String) { + if !s.starts_with("{") { + let mut v = "{\"@type\": \"Card\", \"version\": \"1.0\",\n".to_string(); + v.push_str(s); + v.push_str("\n}\n"); + *s = v; + } + } + + fn parse_vcard(test_name: &str, line_num: usize, s: &str) -> VCard { + VCard::parse(s).unwrap_or_else(|_| { + panic!( + "Failed to parse vCard: {} on line {}, test {}", + s, line_num, test_name + ) + }) + } + + fn parse_jscontact<'x>( + test_name: &str, + line_num: usize, + s: &'x str, + ) -> JSContact<'x, String, String> { + JSContact::parse(s).unwrap_or_else(|_| { + panic!( + "Failed to parse JSContact: {} on line {}, test {}", + s, line_num, test_name + ) + }) + } + + fn sanitize_vcard(mut vcard: VCard) -> VCard { + vcard + .entries + .retain(|e| !matches!(e.name, VCardProperty::Version)); + vcard.entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + vcard + } + + fn sanitize_jscontact( + mut jscontact: JSContact<'_, String, String>, + ) -> JSContact<'_, String, String> { + sort_jscontact_properties(&mut jscontact.0); + jscontact + } + + fn sort_jscontact_properties( + value: &mut Value<'_, JSContactProperty, JSContactValue>, + ) { + match value { + Value::Array(value) => { + for item in value { + sort_jscontact_properties(item); + } + } + Value::Object(obj) => { + obj.as_mut_vec() + .retain(|(k, _)| !matches!(k, Key::Property(JSContactProperty::Type))); + obj.as_mut_vec() + .sort_unstable_by(|a, b| a.0.to_string().cmp(&b.0.to_string())); + for (_, item) in obj.as_mut_vec() { + sort_jscontact_properties(item); + } + } + _ => {} + } + } +} diff --git a/vendor/calcard/src/jscontact/parser.rs b/vendor/calcard/src/jscontact/parser.rs new file mode 100644 index 00000000..f29068e9 --- /dev/null +++ b/vendor/calcard/src/jscontact/parser.rs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{CalendarScale, IanaParse, IdReference}, + jscontact::{ + Context, Feature, JSContact, JSContactGrammaticalGender, JSContactId, JSContactKind, + JSContactLevel, JSContactPhoneticSystem, JSContactProperty, JSContactRelation, + JSContactType, JSContactValue, + }, +}; +use chrono::DateTime; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Value}; +use std::{borrow::Cow, str::FromStr}; + +impl<'x, I, B> JSContact<'x, I, B> +where + I: JSContactId, + B: JSContactId, +{ + pub fn parse(json: &'x str) -> Result { + Value::parse_json(json).map(JSContact) + } + + pub fn to_string_pretty(&self) -> String { + serde_json::to_string_pretty(&self.0).unwrap_or_default() + } +} + +impl Element for JSContactValue +where + I: JSContactId, + B: JSContactId, +{ + type Property = JSContactProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + JSContactProperty::Type => JSContactType::from_str(value) + .ok() + .map(JSContactValue::Type), + JSContactProperty::CalendarScale => { + CalendarScale::parse(value.as_bytes()).map(JSContactValue::CalendarScale) + } + JSContactProperty::Created + | JSContactProperty::Updated + | JSContactProperty::Utc => DateTime::parse_from_rfc3339(value) + .map(|dt| JSContactValue::Timestamp(dt.timestamp())) + .ok(), + JSContactProperty::Kind => JSContactKind::from_str(value) + .ok() + .map(JSContactValue::Kind), + JSContactProperty::GrammaticalGender => JSContactGrammaticalGender::from_str(value) + .ok() + .map(JSContactValue::GrammaticalGender), + JSContactProperty::PhoneticSystem => JSContactPhoneticSystem::from_str(value) + .ok() + .map(JSContactValue::PhoneticSystem), + JSContactProperty::Relation => JSContactRelation::from_str(value) + .ok() + .map(JSContactValue::Relation), + JSContactProperty::Level => JSContactLevel::from_str(value) + .ok() + .map(JSContactValue::Level), + JSContactProperty::BlobId => match IdReference::parse(value) { + IdReference::Value(value) => JSContactValue::BlobId(value).into(), + IdReference::Reference(value) => JSContactValue::IdReference(value).into(), + IdReference::Error => None, + }, + JSContactProperty::Id => match IdReference::parse(value) { + IdReference::Value(value) => JSContactValue::Id(value).into(), + IdReference::Reference(value) => JSContactValue::IdReference(value).into(), + IdReference::Error => None, + }, + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + JSContactValue::Type(v) => v.as_str().into(), + JSContactValue::GrammaticalGender(v) => v.as_str().into(), + JSContactValue::Kind(v) => v.as_str().into(), + JSContactValue::Level(v) => v.as_str().into(), + JSContactValue::Relation(v) => v.as_str().into(), + JSContactValue::PhoneticSystem(v) => v.as_str().into(), + JSContactValue::Timestamp(v) => mail_parser::DateTime::from_timestamp(*v) + .to_rfc3339() + .into(), + JSContactValue::CalendarScale(v) => v.as_js_str().into(), + JSContactValue::Id(v) => v.to_string().into(), + JSContactValue::BlobId(v) => v.to_string().into(), + JSContactValue::IdReference(s) => format!("#{}", s).into(), + } + } +} + +impl jmap_tools::Property for JSContactProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + match key { + Some(Key::Property(key)) => match key.patch_or_prop() { + JSContactProperty::Contexts => Context::from_str(value) + .ok() + .map(JSContactProperty::Context), + JSContactProperty::Features => Feature::from_str(value) + .ok() + .map(JSContactProperty::Feature), + JSContactProperty::SortAs => JSContactKind::from_str(value) + .ok() + .map(JSContactProperty::SortAsKind), + JSContactProperty::ConvertedProperties | JSContactProperty::Localizations => { + JSContactProperty::Pointer(JsonPointer::parse(value)).into() + } + JSContactProperty::AddressBookIds => match IdReference::parse(value) { + IdReference::Value(value) => JSContactProperty::IdValue(value).into(), + IdReference::Reference(value) => JSContactProperty::IdReference(value).into(), + IdReference::Error => None, + }, + _ => JSContactProperty::from_str(value).ok(), + }, + None if value.contains('/') => { + JSContactProperty::Pointer(JsonPointer::parse(value)).into() + } + _ => JSContactProperty::from_str(value).ok(), + } + } + + fn to_cow(&self) -> Cow<'static, str> { + self.to_string() + } +} + +impl JSContactProperty { + fn patch_or_prop(&self) -> &JSContactProperty { + if let JSContactProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} diff --git a/vendor/calcard/src/jscontact/types.rs b/vendor/calcard/src/jscontact/types.rs new file mode 100644 index 00000000..a0675072 --- /dev/null +++ b/vendor/calcard/src/jscontact/types.rs @@ -0,0 +1,561 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::jscontact::*; +use std::str::FromStr; + +impl FromStr for JSContactProperty { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "@type" => JSContactProperty::Type, + "address" => JSContactProperty::Address, + "addressBookIds" => JSContactProperty::AddressBookIds, + "addresses" => JSContactProperty::Addresses, + "anniversaries" => JSContactProperty::Anniversaries, + "author" => JSContactProperty::Author, + "blobId" => JSContactProperty::BlobId, + "calendars" => JSContactProperty::Calendars, + "calendarScale" => JSContactProperty::CalendarScale, + "components" => JSContactProperty::Components, + "contexts" => JSContactProperty::Contexts, + "convertedProperties" => JSContactProperty::ConvertedProperties, + "coordinates" => JSContactProperty::Coordinates, + "countryCode" => JSContactProperty::CountryCode, + "created" => JSContactProperty::Created, + "cryptoKeys" => JSContactProperty::CryptoKeys, + "date" => JSContactProperty::Date, + "day" => JSContactProperty::Day, + "defaultSeparator" => JSContactProperty::DefaultSeparator, + "directories" => JSContactProperty::Directories, + "emails" => JSContactProperty::Emails, + "extra" => JSContactProperty::Extra, + "features" => JSContactProperty::Features, + "full" => JSContactProperty::Full, + "grammaticalGender" => JSContactProperty::GrammaticalGender, + "id" => JSContactProperty::Id, + "isOrdered" => JSContactProperty::IsOrdered, + "keywords" => JSContactProperty::Keywords, + "kind" => JSContactProperty::Kind, + "label" => JSContactProperty::Label, + "language" => JSContactProperty::Language, + "level" => JSContactProperty::Level, + "links" => JSContactProperty::Links, + "listAs" => JSContactProperty::ListAs, + "localizations" => JSContactProperty::Localizations, + "media" => JSContactProperty::Media, + "mediaType" => JSContactProperty::MediaType, + "members" => JSContactProperty::Members, + "month" => JSContactProperty::Month, + "name" => JSContactProperty::Name, + "nicknames" => JSContactProperty::Nicknames, + "note" => JSContactProperty::Note, + "notes" => JSContactProperty::Notes, + "number" => JSContactProperty::Number, + "onlineServices" => JSContactProperty::OnlineServices, + "organizationId" => JSContactProperty::OrganizationId, + "organizations" => JSContactProperty::Organizations, + "parameters" => JSContactProperty::Parameters, + "personalInfo" => JSContactProperty::PersonalInfo, + "phones" => JSContactProperty::Phones, + "phonetic" => JSContactProperty::Phonetic, + "phoneticScript" => JSContactProperty::PhoneticScript, + "phoneticSystem" => JSContactProperty::PhoneticSystem, + "place" => JSContactProperty::Place, + "pref" => JSContactProperty::Pref, + "preferredLanguages" => JSContactProperty::PreferredLanguages, + "prodId" => JSContactProperty::ProdId, + "pronouns" => JSContactProperty::Pronouns, + "properties" => JSContactProperty::Properties, + "relatedTo" => JSContactProperty::RelatedTo, + "relation" => JSContactProperty::Relation, + "schedulingAddresses" => JSContactProperty::SchedulingAddresses, + "service" => JSContactProperty::Service, + "sortAs" => JSContactProperty::SortAs, + "speakToAs" => JSContactProperty::SpeakToAs, + "timeZone" => JSContactProperty::TimeZone, + "titles" => JSContactProperty::Titles, + "uid" => JSContactProperty::Uid, + "units" => JSContactProperty::Units, + "updated" => JSContactProperty::Updated, + "uri" => JSContactProperty::Uri, + "user" => JSContactProperty::User, + "utc" => JSContactProperty::Utc, + "value" => JSContactProperty::Value, + "vCard" => JSContactProperty::VCard, + "version" => JSContactProperty::Version, + "year" => JSContactProperty::Year, + ) + .ok_or(()) + } +} + +impl JSContactProperty { + pub fn to_string(&self) -> Cow<'static, str> { + match self { + JSContactProperty::Type => "@type", + JSContactProperty::Address => "address", + JSContactProperty::AddressBookIds => "addressBookIds", + JSContactProperty::Addresses => "addresses", + JSContactProperty::Anniversaries => "anniversaries", + JSContactProperty::Author => "author", + JSContactProperty::BlobId => "blobId", + JSContactProperty::Calendars => "calendars", + JSContactProperty::CalendarScale => "calendarScale", + JSContactProperty::Components => "components", + JSContactProperty::Contexts => "contexts", + JSContactProperty::ConvertedProperties => "convertedProperties", + JSContactProperty::Coordinates => "coordinates", + JSContactProperty::CountryCode => "countryCode", + JSContactProperty::Created => "created", + JSContactProperty::CryptoKeys => "cryptoKeys", + JSContactProperty::Date => "date", + JSContactProperty::Day => "day", + JSContactProperty::DefaultSeparator => "defaultSeparator", + JSContactProperty::Directories => "directories", + JSContactProperty::Emails => "emails", + JSContactProperty::Extra => "extra", + JSContactProperty::Features => "features", + JSContactProperty::Full => "full", + JSContactProperty::GrammaticalGender => "grammaticalGender", + JSContactProperty::Id => "id", + JSContactProperty::IsOrdered => "isOrdered", + JSContactProperty::Keywords => "keywords", + JSContactProperty::Kind => "kind", + JSContactProperty::Label => "label", + JSContactProperty::Language => "language", + JSContactProperty::Level => "level", + JSContactProperty::Links => "links", + JSContactProperty::ListAs => "listAs", + JSContactProperty::Localizations => "localizations", + JSContactProperty::Media => "media", + JSContactProperty::MediaType => "mediaType", + JSContactProperty::Members => "members", + JSContactProperty::Month => "month", + JSContactProperty::Name => "name", + JSContactProperty::Nicknames => "nicknames", + JSContactProperty::Note => "note", + JSContactProperty::Notes => "notes", + JSContactProperty::Number => "number", + JSContactProperty::OnlineServices => "onlineServices", + JSContactProperty::OrganizationId => "organizationId", + JSContactProperty::Organizations => "organizations", + JSContactProperty::Parameters => "parameters", + JSContactProperty::PersonalInfo => "personalInfo", + JSContactProperty::Phones => "phones", + JSContactProperty::Phonetic => "phonetic", + JSContactProperty::PhoneticScript => "phoneticScript", + JSContactProperty::PhoneticSystem => "phoneticSystem", + JSContactProperty::Place => "place", + JSContactProperty::Pref => "pref", + JSContactProperty::PreferredLanguages => "preferredLanguages", + JSContactProperty::ProdId => "prodId", + JSContactProperty::Pronouns => "pronouns", + JSContactProperty::Properties => "properties", + JSContactProperty::RelatedTo => "relatedTo", + JSContactProperty::Relation => "relation", + JSContactProperty::SchedulingAddresses => "schedulingAddresses", + JSContactProperty::Service => "service", + JSContactProperty::SortAs => "sortAs", + JSContactProperty::SpeakToAs => "speakToAs", + JSContactProperty::TimeZone => "timeZone", + JSContactProperty::Titles => "titles", + JSContactProperty::Uid => "uid", + JSContactProperty::Units => "units", + JSContactProperty::Updated => "updated", + JSContactProperty::Uri => "uri", + JSContactProperty::User => "user", + JSContactProperty::Utc => "utc", + JSContactProperty::Value => "value", + JSContactProperty::VCard => "vCard", + JSContactProperty::Version => "version", + JSContactProperty::Year => "year", + JSContactProperty::Context(context) => context.as_str(), + JSContactProperty::Feature(feature) => feature.as_str(), + JSContactProperty::SortAsKind(kind) => kind.as_str(), + JSContactProperty::Pointer(pointer) => return Cow::Owned(pointer.to_string()), + JSContactProperty::IdValue(id) => return Cow::Owned(id.to_string()), + JSContactProperty::IdReference(s) => return Cow::Owned(format!("#{}", s)), + } + .into() + } +} + +impl FromStr for Context { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), Context, + "billing" => Context::Billing, + "delivery" => Context::Delivery, + "private" => Context::Private, + "work" => Context::Work, + ) + .copied() + .ok_or(()) + } +} + +impl Context { + pub fn as_str(&self) -> &'static str { + match self { + Context::Billing => "billing", + Context::Delivery => "delivery", + Context::Private => "private", + Context::Work => "work", + } + } +} + +impl FromStr for Feature { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), Feature, + "fax" => Feature::Fax, + "main-number" => Feature::MainNumber, + "mobile" => Feature::Mobile, + "pager" => Feature::Pager, + "text" => Feature::Text, + "textphone" => Feature::TextPhone, + "video" => Feature::Video, + "voice" => Feature::Voice, + ) + .copied() + .ok_or(()) + } +} + +impl Feature { + pub fn as_str(&self) -> &'static str { + match self { + Feature::Fax => "fax", + Feature::MainNumber => "main-number", + Feature::Mobile => "mobile", + Feature::Pager => "pager", + Feature::Text => "text", + Feature::TextPhone => "textphone", + Feature::Video => "video", + Feature::Voice => "voice", + } + } +} + +impl FromStr for JSContactGrammaticalGender { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactGrammaticalGender, + "animate" => JSContactGrammaticalGender::Animate, + "common" => JSContactGrammaticalGender::Common, + "feminine" => JSContactGrammaticalGender::Feminine, + "inanimate" => JSContactGrammaticalGender::Inanimate, + "masculine" => JSContactGrammaticalGender::Masculine, + "neuter" => JSContactGrammaticalGender::Neuter, + ) + .copied() + .ok_or(()) + } +} + +impl JSContactGrammaticalGender { + pub fn as_str(&self) -> &'static str { + match self { + JSContactGrammaticalGender::Animate => "animate", + JSContactGrammaticalGender::Common => "common", + JSContactGrammaticalGender::Feminine => "feminine", + JSContactGrammaticalGender::Inanimate => "inanimate", + JSContactGrammaticalGender::Masculine => "masculine", + JSContactGrammaticalGender::Neuter => "neuter", + } + } +} + +impl FromStr for JSContactKind { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactKind, + "apartment" => JSContactKind::Apartment, + "block" => JSContactKind::Block, + "building" => JSContactKind::Building, + "country" => JSContactKind::Country, + "direction" => JSContactKind::Direction, + "district" => JSContactKind::District, + "floor" => JSContactKind::Floor, + "landmark" => JSContactKind::Landmark, + "locality" => JSContactKind::Locality, + "name" => JSContactKind::Name, + "number" => JSContactKind::Number, + "postcode" => JSContactKind::Postcode, + "postOfficeBox" => JSContactKind::PostOfficeBox, + "region" => JSContactKind::Region, + "room" => JSContactKind::Room, + "separator" => JSContactKind::Separator, + "subdistrict" => JSContactKind::Subdistrict, + "birth" => JSContactKind::Birth, + "death" => JSContactKind::Death, + "wedding" => JSContactKind::Wedding, + "calendar" => JSContactKind::Calendar, + "freeBusy" => JSContactKind::FreeBusy, + "application" => JSContactKind::Application, + "device" => JSContactKind::Device, + "group" => JSContactKind::Group, + "individual" => JSContactKind::Individual, + "location" => JSContactKind::Location, + "org" => JSContactKind::Org, + "directory" => JSContactKind::Directory, + "entry" => JSContactKind::Entry, + "contact" => JSContactKind::Contact, + "logo" => JSContactKind::Logo, + "photo" => JSContactKind::Photo, + "sound" => JSContactKind::Sound, + "credential" => JSContactKind::Credential, + "generation" => JSContactKind::Generation, + "given" => JSContactKind::Given, + "given2" => JSContactKind::Given2, + "surname" => JSContactKind::Surname, + "surname2" => JSContactKind::Surname2, + "title" => JSContactKind::Title, + "expertise" => JSContactKind::Expertise, + "hobby" => JSContactKind::Hobby, + "interest" => JSContactKind::Interest, + "role" => JSContactKind::Role, + ) + .copied() + .ok_or(()) + } +} + +#[allow(clippy::wrong_self_convention)] +impl JSContactKind { + pub fn as_str(&self) -> &'static str { + match self { + JSContactKind::Apartment => "apartment", + JSContactKind::Block => "block", + JSContactKind::Building => "building", + JSContactKind::Country => "country", + JSContactKind::Direction => "direction", + JSContactKind::District => "district", + JSContactKind::Floor => "floor", + JSContactKind::Landmark => "landmark", + JSContactKind::Locality => "locality", + JSContactKind::Name => "name", + JSContactKind::Number => "number", + JSContactKind::Postcode => "postcode", + JSContactKind::PostOfficeBox => "postOfficeBox", + JSContactKind::Region => "region", + JSContactKind::Room => "room", + JSContactKind::Separator => "separator", + JSContactKind::Subdistrict => "subdistrict", + JSContactKind::Birth => "birth", + JSContactKind::Death => "death", + JSContactKind::Wedding => "wedding", + JSContactKind::Calendar => "calendar", + JSContactKind::FreeBusy => "freeBusy", + JSContactKind::Application => "application", + JSContactKind::Device => "device", + JSContactKind::Group => "group", + JSContactKind::Individual => "individual", + JSContactKind::Location => "location", + JSContactKind::Org => "org", + JSContactKind::Directory => "directory", + JSContactKind::Entry => "entry", + JSContactKind::Contact => "contact", + JSContactKind::Logo => "logo", + JSContactKind::Photo => "photo", + JSContactKind::Sound => "sound", + JSContactKind::Credential => "credential", + JSContactKind::Generation => "generation", + JSContactKind::Given => "given", + JSContactKind::Given2 => "given2", + JSContactKind::Surname => "surname", + JSContactKind::Surname2 => "surname2", + JSContactKind::Title => "title", + JSContactKind::Expertise => "expertise", + JSContactKind::Hobby => "hobby", + JSContactKind::Interest => "interest", + JSContactKind::Role => "role", + } + } + + pub(crate) fn from_vcard_adr_pos(pos: usize) -> Option { + match pos { + 0 => JSContactKind::PostOfficeBox, + 1 => JSContactKind::Apartment, + 2 => JSContactKind::Name, + 3 => JSContactKind::Locality, + 4 => JSContactKind::Region, + 5 => JSContactKind::Postcode, + 6 => JSContactKind::Country, + 7 => JSContactKind::Room, + 8 => JSContactKind::Apartment, + 9 => JSContactKind::Floor, + 10 => JSContactKind::Number, + 11 => JSContactKind::Name, + 12 => JSContactKind::Building, + 13 => JSContactKind::Block, + 14 => JSContactKind::Subdistrict, + 15 => JSContactKind::District, + 16 => JSContactKind::Landmark, + 17 => JSContactKind::Direction, + _ => return None, + } + .into() + } + + pub(crate) fn to_vcard_adr_pos(&self) -> Option { + match self { + JSContactKind::PostOfficeBox => 0, + JSContactKind::Apartment => 8, + JSContactKind::Name => 11, + JSContactKind::Locality => 3, + JSContactKind::Region => 4, + JSContactKind::Postcode => 5, + JSContactKind::Country => 6, + JSContactKind::Room => 7, + JSContactKind::Floor => 9, + JSContactKind::Number => 10, + JSContactKind::Building => 12, + JSContactKind::Block => 13, + JSContactKind::Subdistrict => 14, + JSContactKind::District => 15, + JSContactKind::Landmark => 16, + JSContactKind::Direction => 17, + _ => return None, + } + .into() + } + + pub(crate) fn from_vcard_n_pos(pos: usize) -> Option { + match pos { + 0 => JSContactKind::Surname, + 1 => JSContactKind::Given, + 2 => JSContactKind::Given2, + 3 => JSContactKind::Title, + 4 => JSContactKind::Credential, + 5 => JSContactKind::Surname2, + 6 => JSContactKind::Generation, + _ => return None, + } + .into() + } + + pub(crate) fn to_vcard_n_pos(&self) -> Option { + match self { + JSContactKind::Surname => 0, + JSContactKind::Given => 1, + JSContactKind::Given2 => 2, + JSContactKind::Title => 3, + JSContactKind::Credential => 4, + JSContactKind::Surname2 => 5, + JSContactKind::Generation => 6, + _ => return None, + } + .into() + } +} + +impl FromStr for JSContactLevel { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactLevel, + "high" => JSContactLevel::High, + "low" => JSContactLevel::Low, + "medium" => JSContactLevel::Medium, + ) + .copied() + .ok_or(()) + } +} + +impl JSContactLevel { + pub fn as_str(&self) -> &'static str { + match self { + JSContactLevel::High => "high", + JSContactLevel::Low => "low", + JSContactLevel::Medium => "medium", + } + } +} + +impl FromStr for JSContactRelation { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactRelation, + "acquaintance" => JSContactRelation::Acquaintance, + "agent" => JSContactRelation::Agent, + "child" => JSContactRelation::Child, + "colleague" => JSContactRelation::Colleague, + "contact" => JSContactRelation::Contact, + "co-resident" => JSContactRelation::CoResident, + "co-worker" => JSContactRelation::CoWorker, + "crush" => JSContactRelation::Crush, + "date" => JSContactRelation::Date, + "emergency" => JSContactRelation::Emergency, + "friend" => JSContactRelation::Friend, + "kin" => JSContactRelation::Kin, + "me" => JSContactRelation::Me, + "met" => JSContactRelation::Met, + "muse" => JSContactRelation::Muse, + "neighbor" => JSContactRelation::Neighbor, + "parent" => JSContactRelation::Parent, + "sibling" => JSContactRelation::Sibling, + "spouse" => JSContactRelation::Spouse, + "sweetheart" => JSContactRelation::Sweetheart, + ) + .copied() + .ok_or(()) + } +} + +impl JSContactRelation { + pub fn as_str(&self) -> &'static str { + match self { + JSContactRelation::Acquaintance => "acquaintance", + JSContactRelation::Agent => "agent", + JSContactRelation::Child => "child", + JSContactRelation::Colleague => "colleague", + JSContactRelation::Contact => "contact", + JSContactRelation::CoResident => "co-resident", + JSContactRelation::CoWorker => "co-worker", + JSContactRelation::Crush => "crush", + JSContactRelation::Date => "date", + JSContactRelation::Emergency => "emergency", + JSContactRelation::Friend => "friend", + JSContactRelation::Kin => "kin", + JSContactRelation::Me => "me", + JSContactRelation::Met => "met", + JSContactRelation::Muse => "muse", + JSContactRelation::Neighbor => "neighbor", + JSContactRelation::Parent => "parent", + JSContactRelation::Sibling => "sibling", + JSContactRelation::Spouse => "spouse", + JSContactRelation::Sweetheart => "sweetheart", + } + } +} + +impl FromStr for JSContactPhoneticSystem { + type Err = (); + fn from_str(s: &str) -> Result { + hashify::map!(s.as_bytes(), JSContactPhoneticSystem, + "ipa" => JSContactPhoneticSystem::Ipa, + "jyut" => JSContactPhoneticSystem::Jyut, + "piny" => JSContactPhoneticSystem::Piny, + "script" => JSContactPhoneticSystem::Script, + ) + .copied() + .ok_or(()) + } +} + +impl JSContactPhoneticSystem { + pub fn as_str(&self) -> &'static str { + match self { + JSContactPhoneticSystem::Ipa => "ipa", + JSContactPhoneticSystem::Jyut => "jyut", + JSContactPhoneticSystem::Piny => "piny", + JSContactPhoneticSystem::Script => "script", + } + } +} diff --git a/vendor/calcard/src/lib.rs b/vendor/calcard/src/lib.rs new file mode 100644 index 00000000..845ea9e2 --- /dev/null +++ b/vendor/calcard/src/lib.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ +#![doc = include_str!("../README.md")] +#![deny(rust_2018_idioms)] +#![forbid(unsafe_code)] +use common::tokenizer::{StopChar, Token}; +use icalendar::{ICalendar, ICalendarComponentType}; +use std::{ + borrow::Cow, + iter::{Enumerate, Peekable}, + slice::Iter, +}; +use vcard::VCard; + +pub mod common; +pub mod datecalc; +pub mod icalendar; +#[cfg(feature = "jmap")] +pub mod jscalendar; +#[cfg(feature = "jmap")] +pub mod jscontact; +pub mod vcard; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Entry { + VCard(VCard), + ICalendar(ICalendar), + InvalidLine(String), + UnexpectedComponentEnd { + expected: ICalendarComponentType, + found: ICalendarComponentType, + }, + UnterminatedComponent(Cow<'static, str>), + TooManyComponents, + Eof, +} + +pub struct Parser<'x> { + pub(crate) input: &'x [u8], + pub(crate) iter: Peekable>>, + pub(crate) strict: bool, + pub(crate) stop_colon: bool, + pub(crate) stop_semicolon: bool, + pub(crate) stop_comma: bool, + pub(crate) stop_equal: bool, + pub(crate) stop_dot: bool, + pub(crate) unfold_qp: bool, + pub(crate) unfold_b64: bool, + pub(crate) unquote: bool, + pub(crate) skip_ws: bool, + pub(crate) strip_ctl: bool, + pub(crate) token_buf: Vec>, + pub(crate) last_token_end: usize, +} + +impl<'x> Parser<'x> { + pub fn new(input: &'x str) -> Self { + let input = input.as_bytes(); + Self { + input, + iter: input.iter().enumerate().peekable(), + strict: false, + stop_colon: true, + stop_semicolon: true, + stop_comma: true, + stop_equal: true, + stop_dot: false, + unfold_qp: false, + unfold_b64: false, + unquote: true, + skip_ws: false, + strip_ctl: false, + token_buf: Vec::with_capacity(10), + last_token_end: usize::MAX, + } + } + + pub fn strict(mut self) -> Self { + self.strict = true; + self + } + + pub fn entry(&mut self) -> Entry { + self.expect_iana_token(); + + loop { + if let Some(token) = self.token() { + if (token.text.eq_ignore_ascii_case(b"BEGIN") + || token.text.eq_ignore_ascii_case("\u{feff}BEGIN".as_bytes())) + && token.stop_char == StopChar::Colon + { + if let Some(token) = self.token() { + if token.stop_char == StopChar::Lf { + hashify::fnc_map_ignore_case!(token.text.as_ref(), + b"VCARD" => { return self.vcard(); }, + b"VCALENDAR" => { return self.icalendar(ICalendarComponentType::VCalendar); }, + b"VEVENT" => { return self.icalendar(ICalendarComponentType::VEvent); }, + b"VTODO" => { return self.icalendar(ICalendarComponentType::VTodo); }, + b"VJOURNAL" => { return self.icalendar(ICalendarComponentType::VJournal); }, + b"VFREEBUSY" => { return self.icalendar(ICalendarComponentType::VFreebusy); }, + b"VTIMEZONE" => { return self.icalendar(ICalendarComponentType::VTimezone); }, + b"VALARM" => { return self.icalendar(ICalendarComponentType::VAlarm); }, + b"STANDARD" => { return self.icalendar(ICalendarComponentType::Standard); }, + b"DAYLIGHT" => { return self.icalendar(ICalendarComponentType::Daylight); }, + b"VAVAILABILITY" => { return self.icalendar(ICalendarComponentType::VAvailability); }, + b"AVAILABLE" => { return self.icalendar(ICalendarComponentType::Available); }, + b"PARTICIPANT" => { return self.icalendar(ICalendarComponentType::Participant); }, + b"VLOCATION" => { return self.icalendar(ICalendarComponentType::VLocation); }, + b"VRESOURCE" => { return self.icalendar(ICalendarComponentType::VResource); }, + _ => { + return self.icalendar(ICalendarComponentType::Other(token.into_string())); + } + ) + } + } else { + return Entry::Eof; + } + } + + let token_start = token.start; + let mut token_end = token.end; + + if token.stop_char != StopChar::Lf { + self.expect_single_value(); + while let Some(token) = self.token() { + token_end = token.end; + if token.stop_char == StopChar::Lf { + break; + } + } + } else if token.text.is_empty() { + continue; + } + + return Entry::InvalidLine( + std::str::from_utf8( + self.input.get(token_start..=token_end).unwrap_or_default(), + ) + .unwrap_or_default() + .to_string(), + ); + } else { + return Entry::Eof; + } + } + } +} diff --git a/vendor/calcard/src/vcard/builder.rs b/vendor/calcard/src/vcard/builder.rs new file mode 100644 index 00000000..b63d6186 --- /dev/null +++ b/vendor/calcard/src/vcard/builder.rs @@ -0,0 +1,347 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::parser::{Boolean, Timestamp}, + vcard::*, +}; + +impl VCardEntry { + pub fn new(name: VCardProperty) -> Self { + Self { + group: None, + name, + params: vec![], + values: vec![], + } + } + + pub fn with_params(mut self, params: Vec) -> Self { + self.params = params; + self + } + + pub fn with_group(mut self, group: Option) -> Self { + self.group = group; + self + } + + pub fn with_value(mut self, value: impl Into) -> Self { + self.values.push(value.into()); + self + } + + pub fn with_values(mut self, values: Vec) -> Self { + self.values = values; + self + } + + pub fn with_param(mut self, param: impl Into) -> Self { + self.params.push(param.into()); + self + } + + pub fn add_param(&mut self, param: impl Into) { + self.params.push(param.into()); + } + + pub fn with_param_opt(mut self, param: Option>) -> Self { + if let Some(param) = param { + self.params.push(param.into()); + } + self + } + + pub fn is_type(&self, typ: &VCardValueType) -> bool { + self.parameters(&VCardParameterName::Value) + .any(|p| matches!(p, VCardParameterValue::ValueType(v) if v == typ)) + } +} + +impl VCardParameter { + pub fn new(name: VCardParameterName, value: impl Into) -> Self { + VCardParameter { + name, + value: value.into(), + } + } + + pub fn language(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Language, + value: value.into(), + } + } + + pub fn value(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Value, + value: value.into(), + } + } + + pub fn pref(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Pref, + value: value.into(), + } + } + + pub fn altid(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Altid, + value: value.into(), + } + } + + pub fn pid(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Pid, + value: value.into(), + } + } + + pub fn typ(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Type, + value: value.into(), + } + } + + pub fn mediatype(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Mediatype, + value: value.into(), + } + } + + pub fn calscale(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Calscale, + value: value.into(), + } + } + + pub fn sort_as(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::SortAs, + value: value.into(), + } + } + + pub fn geo(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Geo, + value: value.into(), + } + } + + pub fn tz(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Tz, + value: value.into(), + } + } + + pub fn index(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Index, + value: value.into(), + } + } + + pub fn level(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Level, + value: value.into(), + } + } + + pub fn group(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Group, + value: value.into(), + } + } + + pub fn cc(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Cc, + value: value.into(), + } + } + + pub fn author(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Author, + value: value.into(), + } + } + + pub fn author_name(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::AuthorName, + value: value.into(), + } + } + + pub fn created(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Created, + value: value.into(), + } + } + + pub fn derived(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Derived, + value: value.into(), + } + } + + pub fn label(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Label, + value: value.into(), + } + } + + pub fn phonetic(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Phonetic, + value: value.into(), + } + } + + pub fn prop_id(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::PropId, + value: value.into(), + } + } + + pub fn script(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Script, + value: value.into(), + } + } + + pub fn service_type(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::ServiceType, + value: value.into(), + } + } + + pub fn username(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Username, + value: value.into(), + } + } + + pub fn jsptr(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Jsptr, + value: value.into(), + } + } + + pub fn jscomps(value: impl Into) -> Self { + VCardParameter { + name: VCardParameterName::Jscomps, + value: value.into(), + } + } +} + +impl From for VCardValue { + fn from(text: String) -> Self { + VCardValue::Text(text) + } +} + +impl From for VCardValue { + fn from(kind: VCardKind) -> Self { + VCardValue::Kind(kind) + } +} + +impl From for VCardParameterValue { + fn from(value: String) -> Self { + VCardParameterValue::Text(value) + } +} + +impl From for VCardParameterValue { + fn from(value: u32) -> Self { + VCardParameterValue::Integer(value) + } +} + +impl From for VCardParameterValue { + fn from(value: VCardType) -> Self { + VCardParameterValue::Type(value) + } +} + +impl From for VCardParameterValue { + fn from(value: VCardValueType) -> Self { + VCardParameterValue::ValueType(value) + } +} + +impl From for VCardParameterValue { + fn from(value: CalendarScale) -> Self { + VCardParameterValue::Calscale(value) + } +} + +impl From for VCardParameterValue { + fn from(value: VCardPhonetic) -> Self { + VCardParameterValue::Phonetic(value) + } +} + +impl From for VCardParameterValue { + fn from(value: VCardLevel) -> Self { + VCardParameterValue::Level(value) + } +} + +impl From for VCardParameterValue { + fn from(value: bool) -> Self { + VCardParameterValue::Bool(value) + } +} + +impl From> for VCardParameterValue { + fn from(value: Vec) -> Self { + VCardParameterValue::Jscomps(value) + } +} + +impl From for VCardParameterValue { + fn from(value: Timestamp) -> Self { + VCardParameterValue::Timestamp(value.0) + } +} + +impl From for VCardParameterValue { + fn from(value: Boolean) -> Self { + VCardParameterValue::Bool(value.0) + } +} + +impl> From> for VCardParameterValue { + fn from(value: IanaType) -> Self { + match value { + IanaType::Iana(v) => v.into(), + IanaType::Other(s) => s.into(), + } + } +} diff --git a/vendor/calcard/src/vcard/media_type.rs b/vendor/calcard/src/vcard/media_type.rs new file mode 100644 index 00000000..4c16fc35 --- /dev/null +++ b/vendor/calcard/src/vcard/media_type.rs @@ -0,0 +1,406 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use std::borrow::Cow; + +pub(crate) fn media_type_from_legacy(property: &str, token: &str) -> Option> { + if token.contains('/') { + Some(Cow::Owned(token.to_ascii_lowercase())) + } else if let Some(media_type) = legacy_alias(property, token.as_bytes()) { + Some(Cow::Borrowed(media_type)) + } else { + let subtype = token.to_ascii_lowercase(); + match property { + "PHOTO" | "LOGO" if is_image_subtype(subtype.as_bytes()) => { + Some(Cow::Owned(format!("image/{subtype}"))) + } + "SOUND" if is_audio_subtype(subtype.as_bytes()) => { + Some(Cow::Owned(format!("audio/{subtype}"))) + } + _ => None, + } + } +} + +pub(crate) fn legacy_media_type(property: &str, media_type: &str) -> Option { + let token = match media_type { + "application/pgp-keys" => "PGP".to_string(), + "application/x-x509-user-cert" => "X509".to_string(), + _ => media_type.split_once('/')?.1.to_ascii_uppercase(), + }; + + (media_type_from_legacy(property, &token).as_deref() == Some(media_type)).then_some(token) +} + +fn legacy_alias(property: &str, token: &[u8]) -> Option<&'static str> { + match property { + "PHOTO" | "LOGO" => hashify::tiny_map_ignore_case!(token, + b"JPG" => "image/jpeg", + b"TIF" => "image/tiff", + ), + "SOUND" => hashify::tiny_map_ignore_case!(token, + b"AIF" => "audio/aiff", + b"AIFF" => "audio/aiff", + b"MP3" => "audio/mpeg", + b"PCM" => "audio/l16", + b"WAV" => "audio/wav", + b"WAVE" => "audio/wav", + ), + "KEY" => hashify::tiny_map_ignore_case!(token, + b"GPG" => "application/pgp-keys", + b"PGP" => "application/pgp-keys", + b"X509" => "application/x-x509-user-cert", + ), + _ => None, + } +} + +fn is_image_subtype(subtype: &[u8]) -> bool { + hashify::set!( + subtype, + "aces", + "apng", + "avci", + "avcs", + "avif", + "bmp", + "cgm", + "dicom-rle", + "dpx", + "emf", + "example", + "fits", + "g3fax", + "gif", + "heic", + "heic-sequence", + "heif", + "heif-sequence", + "hej2k", + "hsj2", + "ief", + "j2c", + "jaii", + "jais", + "jls", + "jp2", + "jpeg", + "jph", + "jphc", + "jpm", + "jpx", + "jxl", + "jxr", + "jxra", + "jxrs", + "jxs", + "jxsc", + "jxsi", + "jxss", + "ktx", + "ktx2", + "naplps", + "png", + "prs.aimg", + "prs.btif", + "prs.pti", + "pwg-raster", + "svg+xml", + "t38", + "tiff", + "tiff-fx", + "vnd.adobe.photoshop", + "vnd.airzip.accelerator.azv", + "vnd.blockfact.facti", + "vnd.clip", + "vnd.cns.inf2", + "vnd.dece.graphic", + "vnd.djvu", + "vnd.dvb.subtitle", + "vnd.dwg", + "vnd.dxf", + "vnd.fastbidsheet", + "vnd.fpx", + "vnd.fst", + "vnd.fujixerox.edmics-mmr", + "vnd.fujixerox.edmics-rlc", + "vnd.globalgraphics.pgb", + "vnd.microsoft.icon", + "vnd.mix", + "vnd.mozilla.apng", + "vnd.ms-modi", + "vnd.net-fpx", + "vnd.pco.b16", + "vnd.radiance", + "vnd.sealed.png", + "vnd.sealedmedia.softseal.gif", + "vnd.sealedmedia.softseal.jpg", + "vnd.sld", + "vnd.svf", + "vnd.tencent.tap", + "vnd.valve.source.texture", + "vnd.wap.wbmp", + "vnd.xiff", + "vnd.zbrush.pcx", + "webp", + "wmf", + "x-emf", + "x-wmf", + ) +} + +fn is_audio_subtype(subtype: &[u8]) -> bool { + hashify::set!( + subtype, + "1d-interleaved-parityfec", + "32kadpcm", + "3gpp", + "3gpp2", + "amr", + "amr-wb", + "atrac-advanced-lossless", + "atrac-x", + "atrac3", + "bv16", + "bv32", + "cn", + "dat12", + "dv", + "dvi4", + "evrc", + "evrc-qcp", + "evrc0", + "evrc1", + "evrcb", + "evrcb0", + "evrcb1", + "evrcnw", + "evrcnw0", + "evrcnw1", + "evrcwb", + "evrcwb0", + "evrcwb1", + "evs", + "g711-0", + "g719", + "g722", + "g7221", + "g723", + "g726-16", + "g726-24", + "g726-32", + "g726-40", + "g728", + "g729", + "g7291", + "g729d", + "g729e", + "gsm", + "gsm-efr", + "gsm-hr-08", + "l16", + "l20", + "l24", + "l8", + "lpc", + "melp", + "melp1200", + "melp2400", + "melp600", + "mp4a-latm", + "mpa", + "pcma", + "pcma-wb", + "pcmu", + "pcmu-wb", + "qcelp", + "red", + "smv", + "smv-qcp", + "smv0", + "tetra_acelp", + "tetra_acelp_bb", + "tsvcis", + "uemclip", + "vdvi", + "vmr-wb", + "aac", + "ac3", + "amr-wb+", + "aptx", + "asc", + "basic", + "clearmode", + "dls", + "dsr-es201108", + "dsr-es202050", + "dsr-es202211", + "dsr-es202212", + "eac3", + "encaprtp", + "example", + "flac", + "flexfec", + "fwdred", + "ilbc", + "ip-mr_v2.5", + "matroska", + "mhas", + "midi-clip", + "mobile-xmf", + "mp4", + "mpa-robust", + "mpeg", + "mpeg4-generic", + "ogg", + "opus", + "parityfec", + "prs.aaud", + "prs.sid", + "raptorfec", + "rtp-enc-aescm128", + "rtp-midi", + "rtploopback", + "rtx", + "scip", + "sofa", + "soundfont", + "sp-midi", + "speex", + "t140c", + "t38", + "telephone-event", + "tone", + "ulpfec", + "usac", + "vnd.3gpp.iufp", + "vnd.4sb", + "vnd.celp", + "vnd.audiokoz", + "vnd.blockfact.facta", + "vnd.cisco.nse", + "vnd.cmles.radio-events", + "vnd.cns.anp1", + "vnd.cns.inf1", + "vnd.dece.audio", + "vnd.digital-winds", + "vnd.dlna.adts", + "vnd.dolby.heaac.1", + "vnd.dolby.heaac.2", + "vnd.dolby.mlp", + "vnd.dolby.mps", + "vnd.dolby.pl2", + "vnd.dolby.pl2x", + "vnd.dolby.pl2z", + "vnd.dolby.pulse.1", + "vnd.dra", + "vnd.dts", + "vnd.dts.hd", + "vnd.dts.uhd", + "vnd.dvb.file", + "vnd.everad.plj", + "vnd.hns.audio", + "vnd.lucent.voice", + "vnd.ms-playready.media.pya", + "vnd.nokia.mobile-xmf", + "vnd.nortel.vbk", + "vnd.nuera.ecelp4800", + "vnd.nuera.ecelp7470", + "vnd.nuera.ecelp9600", + "vnd.octel.sbc", + "vnd.presonus.multitrack", + "vnd.qcelp", + "vnd.rhetorex.32kadpcm", + "vnd.rip", + "vnd.sealedmedia.softseal.mpeg", + "vnd.vmx.cvsd", + "vorbis", + "vorbis-config", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_media_type_from_legacy() { + for (property, token, expected) in [ + ("PHOTO", "JPEG", Some("image/jpeg")), + ("PHOTO", "jpeg", Some("image/jpeg")), + ("PHOTO", "JPG", Some("image/jpeg")), + ("PHOTO", "PNG", Some("image/png")), + ("PHOTO", "GIF", Some("image/gif")), + ("PHOTO", "TIF", Some("image/tiff")), + ("PHOTO", "WEBP", Some("image/webp")), + ("PHOTO", "SVG+XML", Some("image/svg+xml")), + ("PHOTO", "image/jpeg", Some("image/jpeg")), + ("PHOTO", "WORK", None), + ("PHOTO", "HOME", None), + ("PHOTO", "PREF", None), + ("PHOTO", "X-CUSTOM", None), + ("LOGO", "PNG", Some("image/png")), + ("SOUND", "BASIC", Some("audio/basic")), + ("SOUND", "WAVE", Some("audio/wav")), + ("SOUND", "PCM", Some("audio/l16")), + ("SOUND", "MP3", Some("audio/mpeg")), + ("SOUND", "PNG", None), + ("KEY", "PGP", Some("application/pgp-keys")), + ("KEY", "X509", Some("application/x-x509-user-cert")), + ("KEY", "WORK", None), + ("FN", "JPEG", None), + ] { + assert_eq!( + media_type_from_legacy(property, token).as_deref(), + expected, + "failed for {property};TYPE={token}" + ); + } + } + + #[test] + fn test_legacy_media_type() { + for (property, media_type, expected) in [ + ("PHOTO", "image/jpeg", Some("JPEG")), + ("PHOTO", "image/png", Some("PNG")), + ("PHOTO", "image/svg+xml", Some("SVG+XML")), + ("PHOTO", "text/plain", None), + ("PHOTO", "application/pgp-keys", None), + ("PHOTO", "notamediatype", None), + ("SOUND", "audio/basic", Some("BASIC")), + ("SOUND", "audio/wav", Some("WAV")), + ("KEY", "application/pgp-keys", Some("PGP")), + ("KEY", "application/x-x509-user-cert", Some("X509")), + ] { + assert_eq!( + legacy_media_type(property, media_type).as_deref(), + expected, + "failed for {property} {media_type}" + ); + } + } + + #[test] + fn test_legacy_media_type_roundtrip() { + for (property, token) in [ + ("PHOTO", "JPEG"), + ("PHOTO", "JPG"), + ("PHOTO", "PNG"), + ("PHOTO", "TIF"), + ("SOUND", "WAVE"), + ("SOUND", "PCM"), + ("KEY", "PGP"), + ] { + let media_type = media_type_from_legacy(property, token).expect("no media type"); + let token = legacy_media_type(property, &media_type).expect("no legacy token"); + assert_eq!( + media_type_from_legacy(property, &token).as_deref(), + Some(media_type.as_ref()), + "unstable roundtrip for {property} {media_type}" + ); + } + } +} diff --git a/vendor/calcard/src/vcard/mod.rs b/vendor/calcard/src/vcard/mod.rs new file mode 100644 index 00000000..4a9bbbc5 --- /dev/null +++ b/vendor/calcard/src/vcard/mod.rs @@ -0,0 +1,452 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + Entry, Parser, + common::{CalendarScale, Data, IanaParse, IanaString, IanaType, PartialDateTime}, +}; + +pub mod builder; +pub(crate) mod media_type; +pub mod parser; +pub mod types; +pub mod utils; +pub mod writer; + +#[cfg(feature = "rkyv")] +pub mod rkyv_types; +#[cfg(feature = "rkyv")] +pub mod rkyv_utils; +#[cfg(feature = "rkyv")] +pub mod rkyv_writer; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct VCard { + pub entries: Vec, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum VCardVersion { + V2_0 = 20, + V2_1 = 21, + V3_0 = 30, + #[default] + V4_0 = 40, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct VCardEntry { + pub group: Option, + pub name: VCardProperty, + pub params: Vec, + pub values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardProperty { + Other(String), + Begin, + End, + Source, // [RFC6350, Section 6.1.3] + Kind, // [RFC6350, Section 6.1.4] + Xml, // [RFC6350, Section 6.1.5] + Fn, // [RFC6350, Section 6.2.1] + N, // [RFC6350, Section 6.2.2][RFC9554, Section 2.2] + Nickname, // [RFC6350, Section 6.2.3] + Photo, // [RFC6350, Section 6.2.4] + Bday, // [RFC6350, Section 6.2.5] + Anniversary, // [RFC6350, Section 6.2.6] + Gender, // [RFC6350, Section 6.2.7] + Adr, // [RFC6350, Section 6.3.1][RFC9554, Section 2.1] + Tel, // [RFC6350, Section 6.4.1] + Email, // [RFC6350, Section 6.4.2] + Impp, // [RFC6350, Section 6.4.3] + Lang, // [RFC6350, Section 6.4.4] + Tz, // [RFC6350, Section 6.5.1] + Geo, // [RFC6350, Section 6.5.2] + Title, // [RFC6350, Section 6.6.1] + Role, // [RFC6350, Section 6.6.2] + Logo, // [RFC6350, Section 6.6.3] + Org, // [RFC6350, Section 6.6.4] + Member, // [RFC6350, Section 6.6.5] + Related, // [RFC6350, Section 6.6.6] + Categories, // [RFC6350, Section 6.7.1] + Note, // [RFC6350, Section 6.7.2] + Prodid, // [RFC6350, Section 6.7.3] + Rev, // [RFC6350, Section 6.7.4] + Sound, // [RFC6350, Section 6.7.5] + Uid, // [RFC6350, Section 6.7.6] + Clientpidmap, // [RFC6350, Section 6.7.7] + Url, // [RFC6350, Section 6.7.8] + Version, // [RFC6350, Section 6.7.9] + Key, // [RFC6350, Section 6.8.1] + Fburl, // [RFC6350, Section 6.9.1] + Caladruri, // [RFC6350, Section 6.9.2] + Caluri, // [RFC6350, Section 6.9.3] + Birthplace, // [RFC6474, Section 2.1] + Deathplace, // [RFC6474, Section 2.2] + Deathdate, // [RFC6474, Section 2.3] + Expertise, // [RFC6715, Section 2.1] + Hobby, // [RFC6715, Section 2.2] + Interest, // [RFC6715, Section 2.3] + OrgDirectory, // [RFC6715, Section 2.4][RFC Errata 3341] + ContactUri, // [RFC8605, Section 2.1] + Created, // [RFC9554, Section 3.1] + Gramgender, // [RFC9554, Section 3.2] + Language, // [RFC9554, Section 3.3] + Pronouns, // [RFC9554, Section 3.4] + Socialprofile, // [RFC9554, Section 3.5] + Jsprop, // [RFC9555, Section 3.2.1] +} + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub enum VCardValue { + Text(String), + Integer(i64), + Float(f64), + Boolean(bool), + PartialDateTime(PartialDateTime), + Binary(Data), + Sex(VCardSex), + GramGender(VCardGramGender), + Kind(VCardKind), + Component(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub struct VCardParameter { + pub name: VCardParameterName, + pub value: VCardParameterValue, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))] +pub enum VCardParameterValue { + Text(String), + Integer(u32), + Timestamp(i64), + Bool(bool), + ValueType(VCardValueType), + Type(VCardType), + Calscale(CalendarScale), + Level(VCardLevel), + Phonetic(VCardPhonetic), + Jscomps(Vec), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum Jscomp { + Entry { position: u32, value: u32 }, + Separator(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(PartialEq)))] +pub enum VCardParameterName { + Other(String), + Language, // [RFC6350, Section 5.1] + Value, // [RFC6350, Section 5.2] + Pref, // [RFC6350, Section 5.3] + Altid, // [RFC6350, Section 5.4] + Pid, // [RFC6350, Section 5.5] + Type, // [RFC6350, Section 5.6] + Mediatype, // [RFC6350, Section 5.7] + Calscale, // [RFC6350, Section 5.8] + SortAs, // [RFC6350, Section 5.9] + Geo, // [RFC6350, Section 5.10] + Tz, // [RFC6350, Section 5.11] + Index, // [RFC6715, Section 3.1] + Level, // [RFC6715, Section 3.2] + Group, // [RFC7095, Section 8.1] + Cc, // [RFC8605, Section 3.1] + Author, // [RFC9554, Section 4.1] + AuthorName, // [RFC9554, Section 4.2] + Created, // [RFC9554, Section 4.3] + Derived, // [RFC9554, Section 4.4] + Label, // [RFC6350, Section 6.3.1][RFC9554, Section 4.5] + Phonetic, // [RFC9554, Section 4.6] + PropId, // [RFC9554, Section 4.7] + Script, // [RFC9554, Section 4.8] + ServiceType, // [RFC9554, Section 4.9] + Username, // [RFC9554, Section 4.10] + Jsptr, // [RFC9555, Section 3.3.2] + Jscomps, // [RFC9555, Section 3.3.2] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardValueType { + Boolean, // [RFC6350, Section 4.4] + Date, // [RFC6350, Section 4.3.1] + DateAndOrTime, // [RFC6350, Section 4.3.4] + DateTime, // [RFC6350, Section 4.3.3] + Float, // [RFC6350, Section 4.6] + Integer, // [RFC6350, Section 4.5] + LanguageTag, // [RFC6350, Section 4.8] + Text, // [RFC6350, Section 4.1] + Time, // [RFC6350, Section 4.3.2] + Timestamp, // [RFC6350, Section 4.3.5] + Uri, // [RFC6350, Section 4.2] + UtcOffset, // [RFC6350, Section 4.7] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardLevel { + Beginner, // [RFC6715, Section 3.2] + Average, // [RFC6715, Section 3.2] + Expert, // [RFC6715, Section 3.2] + High, // [RFC6715, Section 3.2] + Medium, // [RFC6715, Section 3.2] + Low, // [RFC6715, Section 3.2] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardPhonetic { + Ipa, // [RFC9554, Section 4.6] + Jyut, // [RFC9554, Section 4.6] + Piny, // [RFC9554, Section 4.6] + Script, // [RFC9554, Section 4.6] +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardType { + Work, // [RFC6350, Section 5.6] + Home, // [RFC6350, Section 5.6] + Billing, // [RFC9554, Section 5.1] + Delivery, // [RFC9554, Section 5.2] + Contact, // [RFC6350, Section 6.6.6] + Acquaintance, // [RFC6350, Section 6.6.6] + Friend, // [RFC6350, Section 6.6.6] + Met, // [RFC6350, Section 6.6.6] + CoWorker, // [RFC6350, Section 6.6.6] + Colleague, // [RFC6350, Section 6.6.6] + CoResident, // [RFC6350, Section 6.6.6] + Neighbor, // [RFC6350, Section 6.6.6] + Child, // [RFC6350, Section 6.6.6] + Parent, // [RFC6350, Section 6.6.6] + Sibling, // [RFC6350, Section 6.6.6] + Spouse, // [RFC6350, Section 6.6.6] + Kin, // [RFC6350, Section 6.6.6] + Muse, // [RFC6350, Section 6.6.6] + Crush, // [RFC6350, Section 6.6.6] + Date, // [RFC6350, Section 6.6.6] + Sweetheart, // [RFC6350, Section 6.6.6] + Me, // [RFC6350, Section 6.6.6] + Agent, // [RFC6350, Section 6.6.6] + Emergency, // [RFC6350, Section 6.6.6] + Text, // [RFC6350, Section 6.4.1] + Voice, // [RFC6350, Section 6.4.1] + Fax, // [RFC6350, Section 6.4.1] + Cell, // [RFC6350, Section 6.4.1] + Video, // [RFC6350, Section 6.4.1] + Pager, // [RFC6350, Section 6.4.1] + Textphone, // [RFC6350, Section 6.4.1] + MainNumber, // [RFC7852] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardGramGender { + Animate, // [RFC9554, Section 3.2] + Common, // [RFC9554, Section 3.2] + Feminine, // [RFC9554, Section 3.2] + Inanimate, // [RFC9554, Section 3.2] + Masculine, // [RFC9554, Section 3.2] + Neuter, // [RFC9554, Section 3.2] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardSex { + Male, + Female, + Other, + NoneOrNotApplicable, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + any(test, feature = "serde"), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive) +)] +#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))] +pub enum VCardKind { + Individual, // [RFC6350, Section 6.1.4] + Group, // [RFC6350, Section 6.1.4] + Org, // [RFC6350, Section 6.1.4] + Location, // [RFC6350, Section 6.1.4] + Application, // [RFC6473, Section 3] + Device, // [RFC6869, Section 3] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueSeparator { + None, + Comma, + Semicolon, + SemicolonAndComma, + Skip, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueType { + Vcard(VCardValueType), + Kind, + Sex, + GramGender, +} + +impl ValueType { + pub fn unwrap_vcard(self) -> VCardValueType { + match self { + ValueType::Vcard(v) => v, + _ => VCardValueType::Text, + } + } +} + +impl VCard { + pub fn parse(value: impl AsRef) -> Result { + let mut parser = Parser::new(value.as_ref()); + match parser.entry() { + Entry::VCard(vcard) => Ok(vcard), + other => Err(other), + } + } +} diff --git a/vendor/calcard/src/vcard/parser.rs b/vendor/calcard/src/vcard/parser.rs new file mode 100644 index 00000000..877f6744 --- /dev/null +++ b/vendor/calcard/src/vcard/parser.rs @@ -0,0 +1,1341 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{ + PartialDateTime, VCard, VCardEntry, VCardParameter, VCardParameterName, VCardType, VCardValue, + VCardValueType, ValueSeparator, ValueType, +}; +use crate::{ + Entry, Parser, Token, + common::{ + CalendarScale, Data, Encoding, IanaParse, IanaType, + parser::{Boolean, Integer, Timestamp, parse_digits, parse_small_digits}, + tokenizer::StopChar, + }, + vcard::{ + Jscomp, VCardGramGender, VCardKind, VCardLevel, VCardParameterValue, VCardPhonetic, + VCardProperty, VCardSex, + }, +}; +use mail_parser::decoders::{ + base64::base64_decode, charsets::map::charset_decoder, + quoted_printable::quoted_printable_decode, +}; +use std::{borrow::Cow, iter::Peekable, slice::Iter}; + +struct Params { + params: Vec, + stop_char: StopChar, + data_types: Vec>, + charset: Option, + encoding: Option, + group_name: Option, +} + +impl Parser<'_> { + pub fn vcard(&mut self) -> Entry { + let mut vcard = VCard::default(); + let mut is_v4 = true; + let mut is_valid = false; + + 'outer: loop { + // Fetch property name + self.expect_iana_token(); + self.stop_dot = true; + let mut token = match self.token() { + Some(token) => token, + None => break, + }; + self.stop_dot = false; + + let mut params = Params { + params: Vec::new(), + stop_char: token.stop_char, + data_types: Vec::new(), + group_name: None, + encoding: None, + charset: None, + }; + + // Parse group name + if matches!(token.stop_char, StopChar::Dot) { + params.group_name = token.into_string().into(); + token = match self.token() { + Some(token) => token, + None => break, + }; + params.stop_char = token.stop_char; + } + + // Parse parameters + let name = token.text; + match params.stop_char { + StopChar::Semicolon => { + self.vcard_parameters(&mut params); + } + StopChar::Colon => {} + StopChar::Lf => { + // Invalid line + if name.is_empty() || !self.strict { + continue; + } else { + return Entry::InvalidLine(Token::new(name).into_string()); + } + } + _ => {} + } + + // Invalid stop char, try seeking colon + if !matches!(params.stop_char, StopChar::Colon | StopChar::Lf) { + params.stop_char = self.seek_value_or_eol(); + } + + // Parse property + let name = match VCardProperty::parse(name.as_ref()) { + Some(name) => name, + None => { + if !name.is_empty() { + VCardProperty::Other(Token::new(name).into_string()) + } else { + // Invalid line, skip + if params.stop_char != StopChar::Lf { + self.seek_lf(); + } + continue; + } + } + }; + let mut entry = VCardEntry { + group: params.group_name, + name, + params: params.params, + values: Vec::new(), + }; + + // Parse value + if params.stop_char != StopChar::Lf { + let (default_type, multi_value) = entry.name.default_types(); + + let is_structured = match multi_value { + ValueSeparator::None => { + self.expect_single_value(); + false + } + ValueSeparator::Comma => { + self.expect_multi_value_comma(); + false + } + ValueSeparator::Semicolon => { + self.expect_multi_value_semicolon(); + false + } + ValueSeparator::SemicolonAndComma => { + self.expect_multi_value_semicolon_and_comma(); + true + } + ValueSeparator::Skip => { + is_valid = entry.name == VCardProperty::End; + self.expect_single_value(); + self.token(); + break 'outer; + } + }; + match params.encoding { + Some(Encoding::Base64) => { + if multi_value != ValueSeparator::None { + self.expect_single_value(); + } + self.unfold_b64 = true; + } + Some(Encoding::QuotedPrintable) => { + self.unfold_qp = true; + } + _ => {} + } + + let mut data_types = params.data_types.iter(); + let mut token_idx = 0; + let mut last_is_comma = false; + + while let Some(mut token) = self.token() { + let (is_eol, is_comma) = match token.stop_char { + StopChar::Lf => (true, false), + StopChar::Comma => (false, true), + _ => (false, false), + }; + + // Decode old vCard + if let Some(encoding) = params.encoding { + let (bytes, default_encoding) = match encoding { + Encoding::Base64 => (base64_decode(&token.text), None), + Encoding::QuotedPrintable => { + (quoted_printable_decode(&token.text), "iso-8859-1".into()) + } + }; + if let Some(bytes) = bytes { + if let Some(decoded) = params + .charset + .as_deref() + .or(default_encoding) + .and_then(|charset| { + charset_decoder(charset.as_bytes()) + .map(|decoder| decoder(&bytes)) + }) + { + token.text = Cow::Owned(decoded.into_bytes()); + } else if std::str::from_utf8(&bytes).is_ok() { + token.text = Cow::Owned(bytes); + } else { + entry.values.push(VCardValue::Binary(Data { + data: bytes, + content_type: None, + })); + if is_eol { + break; + } else { + continue; + } + } + } + } + + let default_type = match &default_type { + ValueType::Vcard(default_type) => default_type, + ValueType::Kind if token_idx == 0 => { + if let Some(value) = VCardKind::parse(token.text.as_ref()) { + entry.values.push(VCardValue::Kind(value)); + if is_eol { + break; + } else { + continue; + } + } + &VCardValueType::Text + } + ValueType::Sex if token_idx == 0 => { + if let Some(value) = VCardSex::parse(token.text.as_ref()) { + entry.values.push(VCardValue::Sex(value)); + if is_eol { + break; + } else { + continue; + } + } + &VCardValueType::Text + } + ValueType::GramGender if token_idx == 0 => { + if let Some(value) = VCardGramGender::parse(token.text.as_ref()) { + entry.values.push(VCardValue::GramGender(value)); + if is_eol { + break; + } else { + continue; + } + } + &VCardValueType::Text + } + _ => &VCardValueType::Text, + }; + + let value = match data_types.next().unwrap_or(&IanaType::Iana(*default_type)) { + IanaType::Iana(value) => match value { + VCardValueType::Date if is_v4 => token + .into_vcard_date() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::DateAndOrTime if is_v4 => token + .into_vcard_date_and_or_datetime() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::DateTime if is_v4 => token + .into_vcard_date_time() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::Time if is_v4 => token + .into_vcard_time() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::Timestamp if is_v4 => token + .into_timestamp(true) + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::UtcOffset if is_v4 => token + .into_offset() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::Boolean => VCardValue::Boolean(token.into_boolean()), + VCardValueType::Float => token + .into_float() + .map(VCardValue::Float) + .unwrap_or_else(VCardValue::Text), + VCardValueType::Integer => token + .into_integer() + .map(VCardValue::Integer) + .unwrap_or_else(VCardValue::Text), + VCardValueType::LanguageTag => VCardValue::Text(token.into_string()), + VCardValueType::Text => { + if is_v4 + && matches!( + (&entry.name, token.text.first()), + (VCardProperty::Version, Some(b'1'..=b'3')) + ) + { + is_v4 = false; + } + + VCardValue::Text(token.into_string()) + } + VCardValueType::Uri => token + .into_uri_bytes() + .map(VCardValue::Binary) + .unwrap_or_else(VCardValue::Text), + // VCard 3.0 and older + VCardValueType::Date + | VCardValueType::DateAndOrTime + | VCardValueType::DateTime + | VCardValueType::Time => token + .into_vcard_datetime_or_legacy() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::Timestamp => token + .into_vcard_timestamp_or_legacy() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + VCardValueType::UtcOffset => token + .into_vcard_offset_or_legacy() + .map(VCardValue::PartialDateTime) + .unwrap_or_else(VCardValue::Text), + }, + IanaType::Other(_) => VCardValue::Text(token.into_string()), + }; + + if is_structured { + match (last_is_comma, entry.values.last_mut(), value) { + ( + true, + Some(VCardValue::Component(structured)), + VCardValue::Text(value), + ) => { + structured.push(value); + } + (true, Some(VCardValue::Text(prev_value)), VCardValue::Text(value)) => { + *entry.values.last_mut().unwrap() = + VCardValue::Component(vec![std::mem::take(prev_value), value]); + } + (_, _, value) => { + entry.values.push(value); + } + } + + last_is_comma = is_comma; + } else { + entry.values.push(value); + } + + if is_eol { + break; + } + + token_idx += 1; + } + } else { + entry.values.push(VCardValue::Text(String::new())); + } + + // Add types + if !params.data_types.is_empty() { + entry + .params + .extend(params.data_types.into_iter().map(|dt| VCardParameter { + name: VCardParameterName::Value, + value: match dt { + IanaType::Iana(v) => VCardParameterValue::ValueType(v), + IanaType::Other(v) => VCardParameterValue::Text(v), + }, + })); + } + + if !is_v4 { + entry.normalize_legacy_media_type(); + } + + vcard.entries.push(entry); + } + + if is_valid || !self.strict { + Entry::VCard(vcard) + } else { + Entry::UnterminatedComponent("BEGIN".into()) + } + } + + fn vcard_parameters(&mut self, params: &mut Params) { + while params.stop_char == StopChar::Semicolon { + self.expect_iana_token(); + let token = match self.token() { + Some(token) => token, + None => { + params.stop_char = StopChar::Lf; + break; + } + }; + + // Obtain parameter values + let param_name = token.text; + params.stop_char = token.stop_char; + if !matches!( + params.stop_char, + StopChar::Lf | StopChar::Colon | StopChar::Semicolon + ) { + if params.stop_char != StopChar::Equal { + params.stop_char = self.seek_param_value_or_eol(); + } + if params.stop_char == StopChar::Equal { + self.expect_param_value(); + while !matches!( + params.stop_char, + StopChar::Lf | StopChar::Colon | StopChar::Semicolon + ) { + match self.token() { + Some(token) => { + params.stop_char = token.stop_char; + self.token_buf.push(token); + } + None => { + params.stop_char = StopChar::Lf; + break; + } + } + } + } + } + + let param_values = &mut params.params; + if let Some(param_name) = VCardParameterName::try_parse(param_name.as_ref()) { + if self.token_buf.is_empty() { + param_values.push(VCardParameter::new(param_name, VCardParameterValue::Null)); + continue; + } + + match param_name { + VCardParameterName::Value => { + params + .data_types + .extend(self.token_buf.drain(..).map(Into::into)); + } + VCardParameterName::Pref | VCardParameterName::Index => { + if let Some(value) = self.buf_parse_one::>() { + param_values.push(VCardParameter { + name: param_name, + value: match value { + IanaType::Iana(value) => { + VCardParameterValue::Integer(value.0 as u32) + } + IanaType::Other(value) => VCardParameterValue::Text(value), + }, + }); + } + } + VCardParameterName::Calscale => { + if let Some(value) = self.buf_parse_one::>() + { + param_values.push(VCardParameter::new(param_name, value)); + } + } + VCardParameterName::Phonetic => { + if let Some(value) = self.buf_parse_one::>() + { + param_values.push(VCardParameter::new(param_name, value)); + } + } + VCardParameterName::Level => { + if let Some(value) = self.buf_parse_one::>() { + param_values.push(VCardParameter::new(param_name, value)); + } + } + VCardParameterName::Created => { + if let Some(value) = self.buf_parse_one::>() { + param_values.push(VCardParameter::new(param_name, value)); + } + } + VCardParameterName::Derived => { + if let Some(value) = self.buf_parse_one::>() { + param_values.push(VCardParameter::new(param_name, value)); + } + } + VCardParameterName::Type => { + let mut types: Vec> = self.buf_parse_many(); + + // RFC6350 has many mistakes, this is a workaround for the "TYPE" values + // which in the examples sometimes appears between quotes. + match types.first() { + Some(IanaType::Other(text)) + if types.len() == 1 && text.contains(",") => + { + let mut types_ = Vec::with_capacity(2); + for text in text.split(',') { + if let Some(typ) = VCardType::parse(text.as_bytes()) { + types_.push(IanaType::Iana(typ)); + } + } + types = types_; + } + _ => {} + } + + param_values.extend(types.into_iter().map(VCardParameter::typ)); + } + VCardParameterName::Jscomps => { + if let Some(text) = self.raw_token() { + let mut jscomps = Vec::with_capacity(4); + for (item_pos, item) in text.split(';').enumerate() { + if let Some(item) = item.strip_prefix("s,") { + let mut sep = String::with_capacity(item.len()); + let mut last_is_escape = false; + + for ch in item.chars() { + if ch == '\\' && !last_is_escape { + last_is_escape = true; + continue; + } + last_is_escape = false; + sep.push(ch); + } + + jscomps.push(Jscomp::Separator(sep)); + } else if item_pos != 0 { + let mut position = None; + let mut value = None; + + for (pos, item) in item.split(',').enumerate() { + if pos == 0 { + position = item.parse::().ok(); + } else if pos == 1 { + value = item.parse::().ok(); + } + } + + if let Some(position) = position { + jscomps.push(Jscomp::Entry { + position, + value: value.unwrap_or_default(), + }); + } + } else { + jscomps.push(Jscomp::Separator(item.to_string())); + } + } + + param_values.push(VCardParameter::jscomps( + VCardParameterValue::Jscomps(jscomps), + )); + } + self.token_buf.clear(); + } + _ => { + param_values.extend(self.token_buf.drain(..).map(|token| { + VCardParameter::new( + param_name.clone(), + VCardParameterValue::Text(token.into_string()), + ) + })); + } + } + } else if !param_name.is_empty() { + match VCardType::parse(param_name.as_ref()) { + Some(typ) if self.token_buf.is_empty() => { + param_values.push(VCardParameter::typ(VCardParameterValue::Type(typ))); + } + _ => match param_name.first() { + Some(b'c' | b'C') + if param_name.as_ref().eq_ignore_ascii_case(b"charset") => + { + for token in self.token_buf.drain(..) { + params.charset = token.into_string().into(); + } + } + Some(b'e' | b'E') + if param_name.as_ref().eq_ignore_ascii_case(b"encoding") => + { + for token in self.token_buf.drain(..) { + params.encoding = Encoding::parse(token.text.as_ref()); + } + } + _ => { + if params.encoding.is_none() + && param_name.eq_ignore_ascii_case(b"base64") + { + params.encoding = Some(Encoding::Base64); + } else { + let name = + VCardParameterName::Other(Token::new(param_name).into_string()) + .clone(); + if !self.token_buf.is_empty() { + param_values.extend(self.token_buf.drain(..).map(|token| { + VCardParameter::new( + name.clone(), + VCardParameterValue::Text(token.into_string()), + ) + })); + } else { + param_values + .push(VCardParameter::new(name, VCardParameterValue::Null)); + } + } + } + }, + } + } + } + } +} + +impl Token<'_> { + pub(crate) fn into_vcard_date(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date(&mut self.text.iter().peekable()); + if !dt.is_null() { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_vcard_date_and_or_datetime( + self, + ) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date_and_or_time(&mut self.text.iter().peekable()); + if !dt.is_null() { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_vcard_date_time(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_date_time(&mut self.text.iter().peekable()); + if !dt.is_null() { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_vcard_time(self) -> std::result::Result { + let mut dt = PartialDateTime::default(); + dt.parse_vcard_time(&mut self.text.iter().peekable(), false); + if !dt.is_null() { + Ok(dt) + } else { + Err(self.into_string()) + } + } + + pub(crate) fn into_vcard_timestamp_or_legacy( + self, + ) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_timestamp(&mut self.text.iter().peekable(), false) { + Ok(dt) + } else { + let mut dt = PartialDateTime::default(); + if dt.parse_vcard_date_legacy(&mut self.text.iter().peekable()) { + Ok(dt) + } else { + Err(self.into_string()) + } + } + } + + pub(crate) fn into_vcard_datetime_or_legacy( + self, + ) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_vcard_date_legacy(&mut self.text.iter().peekable()) { + Ok(dt) + } else { + self.into_vcard_date_and_or_datetime() + } + } + + pub(crate) fn into_vcard_offset_or_legacy( + self, + ) -> std::result::Result { + let mut dt = PartialDateTime::default(); + if dt.parse_vcard_zone_legacy(&mut self.text.iter().peekable()) { + Ok(dt) + } else { + self.into_offset() + } + } +} + +impl PartialDateTime { + pub fn parse_vcard_date_legacy(&mut self, iter: &mut Peekable>) -> bool { + let mut idx = 0; + + for ch in iter { + match ch { + b'0'..=b'9' => { + let value = match idx { + 0 => { + if let Some(value) = &mut self.year { + *value = + value.saturating_mul(10).saturating_add((ch - b'0') as u16); + } else { + self.year = Some((ch - b'0') as u16); + } + continue; + } + 1 => &mut self.month, + 2 => &mut self.day, + 3 => &mut self.hour, + 4 => &mut self.minute, + 5 => &mut self.second, + 6 => &mut self.tz_hour, + 7 => &mut self.tz_minute, + _ => return false, + }; + + if let Some(value) = value { + *value = value.saturating_mul(10).saturating_add(ch - b'0'); + } else { + *value = Some(ch - b'0'); + } + } + b'T' | b't' if idx < 3 => { + idx = 3; + } + b'+' if idx <= 5 => { + idx = 6; + } + b'Z' | b'z' if idx == 5 => { + self.tz_hour = Some(0); + self.tz_minute = Some(0); + break; + } + b'-' if idx <= 2 => { + idx += 1; + } + b'-' if idx <= 5 => { + self.tz_minus = true; + idx = 6; + } + b':' if (3..=6).contains(&idx) => { + idx += 1; + } + b' ' | b'\t' | b'\r' | b'\n' => { + continue; + } + _ => return false, + } + } + + self.has_date() || self.has_zone() + } + + pub fn parse_vcard_zone_legacy(&mut self, iter: &mut Peekable>) -> bool { + let mut idx = 0; + + for ch in iter { + match ch { + b'0'..=b'9' => { + let value = match idx { + 0 => &mut self.tz_hour, + 1 => &mut self.tz_minute, + _ => return false, + }; + + if let Some(value) = value { + *value = value.saturating_mul(10).saturating_add(ch - b'0'); + } else { + *value = Some(ch - b'0'); + } + } + b'+' if self.tz_hour.is_none() => {} + b'-' if self.tz_hour.is_none() => { + self.tz_minus = true; + } + b'Z' | b'z' if self.tz_hour.is_none() => { + self.tz_hour = Some(0); + self.tz_minute = Some(0); + break; + } + b':' => { + idx += 1; + } + b' ' | b'\t' | b'\r' | b'\n' => { + continue; + } + _ => return false, + } + } + + self.tz_hour.is_some() && self.tz_minute.is_some() + } + + pub fn parse_vcard_date_time(&mut self, iter: &mut Peekable>) { + self.parse_vcard_date_noreduc(iter); + if matches!(iter.peek(), Some(&&b'T' | &&b't')) { + iter.next(); + self.parse_vcard_time(iter, true); + } + } + + pub fn parse_vcard_date_and_or_time(&mut self, iter: &mut Peekable>) { + self.parse_vcard_date(iter); + if matches!(iter.peek(), Some(&&b'T' | &&b't')) { + iter.next(); + self.parse_vcard_time(iter, false); + } + } + + pub fn parse_vcard_date(&mut self, iter: &mut Peekable>) { + parse_digits(iter, &mut self.year, 4, true); + if self.year.is_some() && iter.peek() == Some(&&b'-') { + iter.next(); + parse_small_digits(iter, &mut self.month, 2, true); + } else { + parse_small_digits(iter, &mut self.month, 2, true); + parse_small_digits(iter, &mut self.day, 2, false); + } + } + + pub fn parse_vcard_date_noreduc(&mut self, iter: &mut Peekable>) { + parse_digits(iter, &mut self.year, 4, true); + parse_small_digits(iter, &mut self.month, 2, true); + parse_small_digits(iter, &mut self.day, 2, false); + } + + pub fn parse_vcard_time(&mut self, iter: &mut Peekable>, mut notrunc: bool) { + for part in [&mut self.hour, &mut self.minute, &mut self.second] { + match iter.peek() { + Some(b'0'..=b'9') => { + notrunc = true; + parse_small_digits(iter, part, 2, false); + } + Some(b'-') if !notrunc => { + iter.next(); + } + _ => break, + } + } + self.parse_zone(iter); + } +} + +#[cfg(test)] +mod tests { + use crate::Entry; + + use super::*; + use std::io::Write; + + #[test] + fn parse_vcard() { + // Read all .vcf files in the test directory + for entry in std::fs::read_dir("resources/vcard").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "vcf") { + let input = match String::from_utf8(std::fs::read(&path).unwrap()) { + Ok(input) => input, + Err(err) => { + // ISO-8859-1 + err.as_bytes() + .iter() + .map(|&b| b as char) + .collect::() + } + }; + let mut parser = Parser::new(&input); + let mut output = std::fs::File::create(path.with_extension("vcf.out")).unwrap(); + let file_name = path.as_path().to_str().unwrap(); + + loop { + match parser.entry() { + Entry::VCard(mut vcard) => { + /*for item in &mut vcard.entries { + if item.name == VCardProperty::Version { + item.values = vec![VCardValue::Text("4.0".into())]; + } + }*/ + let vcard_text = vcard.to_string(); + crate::common::writer::assert_fold_width(&vcard_text, file_name); + writeln!(output, "{}", vcard_text).unwrap(); + let _vcard_orig = vcard.clone(); + + // Roundtrip parsing + let mut parser = Parser::new(&vcard_text); + match parser.entry() { + Entry::VCard(mut vcard_) => { + vcard.entries.retain(|entry| { + !matches!(entry.name, VCardProperty::Version) + }); + vcard_.entries.retain(|entry| { + !matches!(entry.name, VCardProperty::Version) + }); + assert_eq!(vcard.entries.len(), vcard_.entries.len()); + + if !file_name.contains("003.vcf") { + for (entry, entry_) in + vcard.entries.iter().zip(vcard_.entries.iter()) + { + if entry != entry_ + && matches!( + (entry.values.first(), entry_.values.first()), + ( + Some(VCardValue::Binary(_),), + Some(VCardValue::Text(_)) + ) + ) + { + continue; + } + assert_eq!(entry, entry_, "failed for {file_name}"); + } + } + } + other => panic!("Expected VCard, got {other:?} for {file_name}"), + } + + // Rkyv archiving tests + #[cfg(feature = "rkyv")] + { + let vcard_bytes = + rkyv::to_bytes::(&_vcard_orig).unwrap(); + let vcard_unarchived = rkyv::access::< + crate::vcard::ArchivedVCard, + rkyv::rancor::Error, + >( + &vcard_bytes + ) + .unwrap(); + assert_eq!(vcard_text, vcard_unarchived.to_string()); + + for version in [ + crate::vcard::VCardVersion::V3_0, + crate::vcard::VCardVersion::V4_0, + ] { + let mut native = String::new(); + let mut archived = String::new(); + _vcard_orig.write_to(&mut native, version).unwrap(); + vcard_unarchived.write_to(&mut archived, version).unwrap(); + assert_eq!( + native, archived, + "archived writer diverged at {version} for {file_name}" + ); + crate::common::writer::assert_fold_width(&native, file_name); + } + } + } + Entry::InvalidLine(text) => { + println!("Invalid line in {file_name}: {text}"); + } + Entry::Eof => break, + other => { + panic!("Expected VCard, got {other:?} for {file_name}"); + } + } + } + } + } + } + + #[test] + fn test_parse_dates() { + for (input, typ, expected) in [ + ( + "19850412", + VCardValueType::Date, + PartialDateTime { + year: Some(1985), + month: Some(4), + day: Some(12), + ..Default::default() + }, + ), + ( + "1985-04", + VCardValueType::Date, + PartialDateTime { + year: Some(1985), + month: Some(4), + ..Default::default() + }, + ), + ( + "1985", + VCardValueType::Date, + PartialDateTime { + year: Some(1985), + ..Default::default() + }, + ), + ( + "--0412", + VCardValueType::Date, + PartialDateTime { + month: Some(4), + day: Some(12), + ..Default::default() + }, + ), + ( + "---12", + VCardValueType::Date, + PartialDateTime { + day: Some(12), + ..Default::default() + }, + ), + ( + "102200", + VCardValueType::Time, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + ..Default::default() + }, + ), + ( + "1022", + VCardValueType::Time, + PartialDateTime { + hour: Some(10), + minute: Some(22), + ..Default::default() + }, + ), + ( + "10", + VCardValueType::Time, + PartialDateTime { + hour: Some(10), + ..Default::default() + }, + ), + ( + "-2200", + VCardValueType::Time, + PartialDateTime { + minute: Some(22), + second: Some(0), + ..Default::default() + }, + ), + ( + "--00", + VCardValueType::Time, + PartialDateTime { + second: Some(0), + ..Default::default() + }, + ), + ( + "102200Z", + VCardValueType::Time, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + ..Default::default() + }, + ), + ( + "102200-0800", + VCardValueType::Time, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + tz_hour: Some(8), + tz_minute: Some(0), + tz_minus: true, + ..Default::default() + }, + ), + ( + "19961022T140000", + VCardValueType::DateTime, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + ..Default::default() + }, + ), + ( + "--1022T1400", + VCardValueType::DateTime, + PartialDateTime { + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + ..Default::default() + }, + ), + ( + "---22T14", + VCardValueType::DateTime, + PartialDateTime { + day: Some(22), + hour: Some(14), + ..Default::default() + }, + ), + ( + "19961022T140000", + VCardValueType::DateAndOrTime, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + ..Default::default() + }, + ), + ( + "--1022T1400", + VCardValueType::DateAndOrTime, + PartialDateTime { + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + ..Default::default() + }, + ), + ( + "---22T14", + VCardValueType::DateAndOrTime, + PartialDateTime { + day: Some(22), + hour: Some(14), + ..Default::default() + }, + ), + ( + "19850412", + VCardValueType::DateAndOrTime, + PartialDateTime { + year: Some(1985), + month: Some(4), + day: Some(12), + ..Default::default() + }, + ), + ( + "1985-04", + VCardValueType::DateAndOrTime, + PartialDateTime { + year: Some(1985), + month: Some(4), + ..Default::default() + }, + ), + ( + "1985", + VCardValueType::DateAndOrTime, + PartialDateTime { + year: Some(1985), + ..Default::default() + }, + ), + ( + "--0412", + VCardValueType::DateAndOrTime, + PartialDateTime { + month: Some(4), + day: Some(12), + ..Default::default() + }, + ), + ( + "---12", + VCardValueType::DateAndOrTime, + PartialDateTime { + day: Some(12), + ..Default::default() + }, + ), + ( + "T102200", + VCardValueType::DateAndOrTime, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + ..Default::default() + }, + ), + ( + "T1022", + VCardValueType::DateAndOrTime, + PartialDateTime { + hour: Some(10), + minute: Some(22), + ..Default::default() + }, + ), + ( + "T10", + VCardValueType::DateAndOrTime, + PartialDateTime { + hour: Some(10), + ..Default::default() + }, + ), + ( + "T-2200", + VCardValueType::DateAndOrTime, + PartialDateTime { + minute: Some(22), + second: Some(0), + ..Default::default() + }, + ), + ( + "T--00", + VCardValueType::DateAndOrTime, + PartialDateTime { + second: Some(0), + ..Default::default() + }, + ), + ( + "T102200Z", + VCardValueType::DateAndOrTime, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + ..Default::default() + }, + ), + ( + "T102200-0800", + VCardValueType::DateAndOrTime, + PartialDateTime { + hour: Some(10), + minute: Some(22), + second: Some(0), + tz_hour: Some(8), + tz_minute: Some(0), + tz_minus: true, + ..Default::default() + }, + ), + ( + "19961022T140000", + VCardValueType::Timestamp, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + ..Default::default() + }, + ), + ( + "19961022T140000Z", + VCardValueType::Timestamp, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + tz_hour: Some(0), + tz_minute: Some(0), + ..Default::default() + }, + ), + ( + "19961022T140000-05", + VCardValueType::Timestamp, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + tz_hour: Some(5), + tz_minus: true, + ..Default::default() + }, + ), + ( + "19961022T140000-0500", + VCardValueType::Timestamp, + PartialDateTime { + year: Some(1996), + month: Some(10), + day: Some(22), + hour: Some(14), + minute: Some(0), + second: Some(0), + tz_hour: Some(5), + tz_minute: Some(0), + tz_minus: true, + }, + ), + ( + "-0500", + VCardValueType::UtcOffset, + PartialDateTime { + tz_hour: Some(5), + tz_minute: Some(0), + tz_minus: true, + ..Default::default() + }, + ), + ] { + let mut iter = input.as_bytes().iter().peekable(); + let mut dt = PartialDateTime::default(); + + match typ { + VCardValueType::Date => dt.parse_vcard_date(&mut iter), + VCardValueType::DateAndOrTime => dt.parse_vcard_date_and_or_time(&mut iter), + VCardValueType::DateTime => dt.parse_vcard_date_time(&mut iter), + VCardValueType::Time => dt.parse_vcard_time(&mut iter, false), + VCardValueType::Timestamp => { + dt.parse_timestamp(&mut iter, true); + } + VCardValueType::UtcOffset => { + dt.parse_zone(&mut iter); + } + _ => unreachable!(), + } + + assert_eq!(dt, expected, "failed for {input:?} with type {typ:?}"); + let mut dt_str = String::new(); + dt.format_as_vcard(&mut dt_str, &typ).unwrap(); + + assert_eq!( + input, dt_str, + "roundtrip failed for {input} with type {typ:?} {dt:?}" + ); + } + } +} diff --git a/vendor/calcard/src/vcard/rkyv_types.rs b/vendor/calcard/src/vcard/rkyv_types.rs new file mode 100644 index 00000000..232f4866 --- /dev/null +++ b/vendor/calcard/src/vcard/rkyv_types.rs @@ -0,0 +1,414 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; + +impl ArchivedVCardProperty { + pub fn as_str(&self) -> &str { + match self { + ArchivedVCardProperty::Source => "SOURCE", + ArchivedVCardProperty::Kind => "KIND", + ArchivedVCardProperty::Xml => "XML", + ArchivedVCardProperty::Fn => "FN", + ArchivedVCardProperty::N => "N", + ArchivedVCardProperty::Nickname => "NICKNAME", + ArchivedVCardProperty::Photo => "PHOTO", + ArchivedVCardProperty::Bday => "BDAY", + ArchivedVCardProperty::Anniversary => "ANNIVERSARY", + ArchivedVCardProperty::Gender => "GENDER", + ArchivedVCardProperty::Adr => "ADR", + ArchivedVCardProperty::Tel => "TEL", + ArchivedVCardProperty::Email => "EMAIL", + ArchivedVCardProperty::Impp => "IMPP", + ArchivedVCardProperty::Lang => "LANG", + ArchivedVCardProperty::Tz => "TZ", + ArchivedVCardProperty::Geo => "GEO", + ArchivedVCardProperty::Title => "TITLE", + ArchivedVCardProperty::Role => "ROLE", + ArchivedVCardProperty::Logo => "LOGO", + ArchivedVCardProperty::Org => "ORG", + ArchivedVCardProperty::Member => "MEMBER", + ArchivedVCardProperty::Related => "RELATED", + ArchivedVCardProperty::Categories => "CATEGORIES", + ArchivedVCardProperty::Note => "NOTE", + ArchivedVCardProperty::Prodid => "PRODID", + ArchivedVCardProperty::Rev => "REV", + ArchivedVCardProperty::Sound => "SOUND", + ArchivedVCardProperty::Uid => "UID", + ArchivedVCardProperty::Clientpidmap => "CLIENTPIDMAP", + ArchivedVCardProperty::Url => "URL", + ArchivedVCardProperty::Version => "VERSION", + ArchivedVCardProperty::Key => "KEY", + ArchivedVCardProperty::Fburl => "FBURL", + ArchivedVCardProperty::Caladruri => "CALADRURI", + ArchivedVCardProperty::Caluri => "CALURI", + ArchivedVCardProperty::Birthplace => "BIRTHPLACE", + ArchivedVCardProperty::Deathplace => "DEATHPLACE", + ArchivedVCardProperty::Deathdate => "DEATHDATE", + ArchivedVCardProperty::Expertise => "EXPERTISE", + ArchivedVCardProperty::Hobby => "HOBBY", + ArchivedVCardProperty::Interest => "INTEREST", + ArchivedVCardProperty::OrgDirectory => "ORG-DIRECTORY", + ArchivedVCardProperty::ContactUri => "CONTACT-URI", + ArchivedVCardProperty::Created => "CREATED", + ArchivedVCardProperty::Gramgender => "GRAMGENDER", + ArchivedVCardProperty::Language => "LANGUAGE", + ArchivedVCardProperty::Pronouns => "PRONOUNS", + ArchivedVCardProperty::Socialprofile => "SOCIALPROFILE", + ArchivedVCardProperty::Jsprop => "JSPROP", + ArchivedVCardProperty::Begin => "BEGIN", + ArchivedVCardProperty::End => "END", + ArchivedVCardProperty::Other(s) => s.as_str(), + } + } +} + +impl ArchivedVCardProperty { + // Returns the default value type and whether the property is multi-valued. + pub(crate) fn default_types(&self) -> (ArchivedValueType, ValueSeparator) { + match self { + ArchivedVCardProperty::Source => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Kind => (ArchivedValueType::Kind, ValueSeparator::None), + ArchivedVCardProperty::Xml => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Fn => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::N => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::SemicolonAndComma, + ), + ArchivedVCardProperty::Nickname => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::Comma, + ), + ArchivedVCardProperty::Photo => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Bday => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + ArchivedVCardProperty::Anniversary => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + ArchivedVCardProperty::Gender => (ArchivedValueType::Sex, ValueSeparator::Semicolon), + ArchivedVCardProperty::Adr => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::SemicolonAndComma, + ), + ArchivedVCardProperty::Tel => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Email => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Impp => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Lang => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Tz => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::UtcOffset), + ValueSeparator::None, + ), + ArchivedVCardProperty::Geo => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Title => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Role => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Logo => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Org => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::Semicolon, + ), + ArchivedVCardProperty::Member => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Related => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Categories => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::Comma, + ), + ArchivedVCardProperty::Note => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Prodid => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Rev => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Timestamp), + ValueSeparator::None, + ), + ArchivedVCardProperty::Sound => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Uid => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Clientpidmap => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::Semicolon, + ), + ArchivedVCardProperty::Url => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Version => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Key => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Fburl => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Caladruri => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Caluri => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Birthplace => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Deathplace => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Deathdate => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + ArchivedVCardProperty::Expertise => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Hobby => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Interest => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::OrgDirectory => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::ContactUri => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Created => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Timestamp), + ValueSeparator::None, + ), + ArchivedVCardProperty::Gramgender => { + (ArchivedValueType::GramGender, ValueSeparator::None) + } + ArchivedVCardProperty::Language => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::LanguageTag), + ValueSeparator::None, + ), + ArchivedVCardProperty::Pronouns => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Socialprofile => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Uri), + ValueSeparator::None, + ), + ArchivedVCardProperty::Jsprop => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + ArchivedVCardProperty::Begin | ArchivedVCardProperty::End => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::Skip, + ), + ArchivedVCardProperty::Other(_) => ( + ArchivedValueType::Vcard(ArchivedVCardValueType::Text), + ValueSeparator::None, + ), + } + } +} + +impl IanaString for ArchivedVCardValueType { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardValueType::Boolean => "BOOLEAN", + ArchivedVCardValueType::Date => "DATE", + ArchivedVCardValueType::DateAndOrTime => "DATE-AND-OR-TIME", + ArchivedVCardValueType::DateTime => "DATE-TIME", + ArchivedVCardValueType::Float => "FLOAT", + ArchivedVCardValueType::Integer => "INTEGER", + ArchivedVCardValueType::LanguageTag => "LANGUAGE-TAG", + ArchivedVCardValueType::Text => "TEXT", + ArchivedVCardValueType::Time => "TIME", + ArchivedVCardValueType::Timestamp => "TIMESTAMP", + ArchivedVCardValueType::Uri => "URI", + ArchivedVCardValueType::UtcOffset => "UTC-OFFSET", + } + } +} + +impl IanaString for ArchivedVCardLevel { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardLevel::Beginner => "BEGINNER", + ArchivedVCardLevel::Average => "AVERAGE", + ArchivedVCardLevel::Expert => "EXPERT", + ArchivedVCardLevel::High => "HIGH", + ArchivedVCardLevel::Medium => "MEDIUM", + ArchivedVCardLevel::Low => "LOW", + } + } +} + +impl IanaString for ArchivedVCardPhonetic { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardPhonetic::Ipa => "IPA", + ArchivedVCardPhonetic::Jyut => "JYUT", + ArchivedVCardPhonetic::Piny => "PINY", + ArchivedVCardPhonetic::Script => "SCRIPT", + } + } +} + +impl IanaString for ArchivedVCardType { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardType::Work => "WORK", + ArchivedVCardType::Home => "HOME", + ArchivedVCardType::Billing => "BILLING", + ArchivedVCardType::Delivery => "DELIVERY", + ArchivedVCardType::Contact => "CONTACT", + ArchivedVCardType::Acquaintance => "ACQUAINTANCE", + ArchivedVCardType::Friend => "FRIEND", + ArchivedVCardType::Met => "MET", + ArchivedVCardType::CoWorker => "CO-WORKER", + ArchivedVCardType::Colleague => "COLLEAGUE", + ArchivedVCardType::CoResident => "CO-RESIDENT", + ArchivedVCardType::Neighbor => "NEIGHBOR", + ArchivedVCardType::Child => "CHILD", + ArchivedVCardType::Parent => "PARENT", + ArchivedVCardType::Sibling => "SIBLING", + ArchivedVCardType::Spouse => "SPOUSE", + ArchivedVCardType::Kin => "KIN", + ArchivedVCardType::Muse => "MUSE", + ArchivedVCardType::Crush => "CRUSH", + ArchivedVCardType::Date => "DATE", + ArchivedVCardType::Sweetheart => "SWEETHEART", + ArchivedVCardType::Me => "ME", + ArchivedVCardType::Agent => "AGENT", + ArchivedVCardType::Emergency => "EMERGENCY", + ArchivedVCardType::Text => "TEXT", + ArchivedVCardType::Voice => "VOICE", + ArchivedVCardType::Fax => "FAX", + ArchivedVCardType::Cell => "CELL", + ArchivedVCardType::Video => "VIDEO", + ArchivedVCardType::Pager => "PAGER", + ArchivedVCardType::Textphone => "TEXTPHONE", + ArchivedVCardType::MainNumber => "MAIN-NUMBER", + } + } +} + +impl IanaString for ArchivedVCardGramGender { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardGramGender::Animate => "ANIMATE", + ArchivedVCardGramGender::Common => "COMMON", + ArchivedVCardGramGender::Feminine => "FEMININE", + ArchivedVCardGramGender::Inanimate => "INANIMATE", + ArchivedVCardGramGender::Masculine => "MASCULINE", + ArchivedVCardGramGender::Neuter => "NEUTER", + } + } +} + +impl IanaString for ArchivedVCardSex { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardSex::Male => "M", + ArchivedVCardSex::Female => "F", + ArchivedVCardSex::Other => "O", + ArchivedVCardSex::NoneOrNotApplicable => "N", + ArchivedVCardSex::Unknown => "U", + } + } +} + +impl IanaString for ArchivedVCardKind { + fn as_str(&self) -> &'static str { + match self { + ArchivedVCardKind::Individual => "INDIVIDUAL", + ArchivedVCardKind::Group => "GROUP", + ArchivedVCardKind::Org => "ORG", + ArchivedVCardKind::Location => "LOCATION", + ArchivedVCardKind::Application => "APPLICATION", + ArchivedVCardKind::Device => "DEVICE", + } + } +} + +#[derive(Debug)] + +pub(crate) enum ArchivedValueType { + Vcard(ArchivedVCardValueType), + Kind, + Sex, + GramGender, +} + +impl ArchivedValueType { + pub fn unwrap_vcard(self) -> ArchivedVCardValueType { + match self { + ArchivedValueType::Vcard(v) => v, + _ => ArchivedVCardValueType::Text, + } + } +} diff --git a/vendor/calcard/src/vcard/rkyv_utils.rs b/vendor/calcard/src/vcard/rkyv_utils.rs new file mode 100644 index 00000000..617de3c5 --- /dev/null +++ b/vendor/calcard/src/vcard/rkyv_utils.rs @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::*; +use crate::common::{ArchivedCalendarScale, ArchivedData, ArchivedPartialDateTime}; + +impl ArchivedVCard { + pub fn uid(&self) -> Option<&str> { + self.property(&VCardProperty::Uid) + .and_then(|e| e.values.first()) + .and_then(|v| v.as_text()) + } + + pub fn property(&self, prop: &VCardProperty) -> Option<&ArchivedVCardEntry> { + self.entries.iter().find(|entry| &entry.name == prop) + } + + pub fn properties<'x, 'y: 'x>( + &'x self, + prop: &'y VCardProperty, + ) -> impl Iterator + 'x { + self.entries.iter().filter(move |entry| &entry.name == prop) + } + + pub fn version(&self) -> Option { + self.entries + .iter() + .find(|e| e.name == VCardProperty::Version) + .and_then(|e| { + e.values + .first() + .and_then(|v| v.as_text()) + .and_then(VCardVersion::try_parse) + }) + } +} + +impl ArchivedVCardEntry { + #[inline] + pub fn parameters( + &self, + prop: &VCardParameterName, + ) -> impl Iterator { + self.params.iter().filter_map(move |param| { + if ¶m.name == prop { + Some(¶m.value) + } else { + None + } + }) + } +} + +impl ArchivedVCardValue { + pub fn as_text(&self) -> Option<&str> { + match self { + ArchivedVCardValue::Text(s) => Some(s), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + ArchivedVCardValue::Integer(i) => Some(i.to_native()), + _ => None, + } + } + + pub fn as_float(&self) -> Option { + match self { + ArchivedVCardValue::Float(f) => Some(f.to_native()), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option { + match self { + ArchivedVCardValue::Boolean(b) => Some(*b), + _ => None, + } + } + + pub fn as_partial_date_time(&self) -> Option<&ArchivedPartialDateTime> { + match self { + ArchivedVCardValue::PartialDateTime(dt) => Some(dt), + _ => None, + } + } + + pub fn as_binary(&self) -> Option<&ArchivedData> { + match self { + ArchivedVCardValue::Binary(d) => Some(d), + _ => None, + } + } +} + +impl ArchivedVCardParameterName { + pub fn as_str(&self) -> &str { + match self { + ArchivedVCardParameterName::Language => "LANGUAGE", + ArchivedVCardParameterName::Value => "VALUE", + ArchivedVCardParameterName::Pref => "PREF", + ArchivedVCardParameterName::Altid => "ALTID", + ArchivedVCardParameterName::Pid => "PID", + ArchivedVCardParameterName::Type => "TYPE", + ArchivedVCardParameterName::Mediatype => "MEDIATYPE", + ArchivedVCardParameterName::Calscale => "CALSCALE", + ArchivedVCardParameterName::SortAs => "SORT-AS", + ArchivedVCardParameterName::Geo => "GEO", + ArchivedVCardParameterName::Tz => "TZ", + ArchivedVCardParameterName::Index => "INDEX", + ArchivedVCardParameterName::Level => "LEVEL", + ArchivedVCardParameterName::Group => "GROUP", + ArchivedVCardParameterName::Cc => "CC", + ArchivedVCardParameterName::Author => "AUTHOR", + ArchivedVCardParameterName::AuthorName => "AUTHOR-NAME", + ArchivedVCardParameterName::Created => "CREATED", + ArchivedVCardParameterName::Derived => "DERIVED", + ArchivedVCardParameterName::Label => "LABEL", + ArchivedVCardParameterName::Phonetic => "PHONETIC", + ArchivedVCardParameterName::PropId => "PROP-ID", + ArchivedVCardParameterName::Script => "SCRIPT", + ArchivedVCardParameterName::ServiceType => "SERVICE-TYPE", + ArchivedVCardParameterName::Username => "USERNAME", + ArchivedVCardParameterName::Jsptr => "JSPTR", + ArchivedVCardParameterName::Jscomps => "JSCOMPS", + ArchivedVCardParameterName::Other(name) => name.as_str(), + } + } +} + +impl ArchivedVCardParameterValue { + pub fn as_text(&self) -> Option<&str> { + match self { + ArchivedVCardParameterValue::Text(v) => Some(v.as_str()), + ArchivedVCardParameterValue::ValueType(v) => Some(v.as_str()), + ArchivedVCardParameterValue::Type(v) => Some(v.as_str()), + ArchivedVCardParameterValue::Calscale(v) => Some(v.as_str()), + ArchivedVCardParameterValue::Level(v) => Some(v.as_str()), + ArchivedVCardParameterValue::Phonetic(v) => Some(v.as_str()), + ArchivedVCardParameterValue::Jscomps(_) + | ArchivedVCardParameterValue::Integer(_) + | ArchivedVCardParameterValue::Timestamp(_) + | ArchivedVCardParameterValue::Bool(_) + | ArchivedVCardParameterValue::Null => None, + } + } + + pub fn as_phonetic(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Phonetic(v) => Some(IanaType::Iana(v)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_calscale(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Calscale(v) => Some(IanaType::Iana(v)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_level(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Level(v) => Some(IanaType::Iana(v)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_type(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Type(v) => Some(IanaType::Iana(v)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_value_type(&self) -> Option> { + match self { + ArchivedVCardParameterValue::ValueType(v) => Some(IanaType::Iana(v)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_bool(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Bool(b) => Some(IanaType::Iana(*b)), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_integer(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Integer(i) => Some(IanaType::Iana(i.to_native())), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_timestamp(&self) -> Option> { + match self { + ArchivedVCardParameterValue::Timestamp(t) => Some(IanaType::Iana(t.to_native())), + ArchivedVCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } +} diff --git a/vendor/calcard/src/vcard/rkyv_writer.rs b/vendor/calcard/src/vcard/rkyv_writer.rs new file mode 100644 index 00000000..faf8b3ef --- /dev/null +++ b/vendor/calcard/src/vcard/rkyv_writer.rs @@ -0,0 +1,596 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::{ + common::{ + parser::Timestamp, + writer::{FoldingWriter, LineWriter, write_bytes, write_param_value, write_text}, + }, + vcard::{media_type::legacy_media_type, *}, +}; +use std::fmt::{Display, Write}; + +impl ArchivedVCard { + pub fn write_to(&self, out: &mut impl Write, version: VCardVersion) -> std::fmt::Result { + write!(out, "BEGIN:VCARD\r\n")?; + write!(out, "VERSION:{version}\r\n")?; + let is_v4 = matches!(version, VCardVersion::V4_0); + for entry in self.entries.iter() { + if !matches!( + entry.name, + ArchivedVCardProperty::Version + | ArchivedVCardProperty::Begin + | ArchivedVCardProperty::End + ) { + entry.write_to(out, true, is_v4)?; + } + } + + write!(out, "END:VCARD\r\n") + } +} + +impl ArchivedVCardEntry { + pub fn write_to( + &self, + out: &mut impl Write, + with_value: bool, + is_v4: bool, + ) -> std::fmt::Result { + let mut folded = FoldingWriter::new(out); + let out = &mut folded; + + if let Some(group_name) = self.group.as_ref() { + out.write_atomic(group_name)?; + out.write_atomic(".")?; + } + + out.write_atomic(self.name.as_str())?; + let mut types = None; + let mut last_param: Option<&ArchivedVCardParameterName> = None; + + for param in self.params.iter() { + if last_param.is_some_and(|last_param| last_param == ¶m.name) { + out.write_atomic(",")?; + } else { + out.write_atomic(";")?; + out.write_atomic(param.name.as_str())?; + if !matches!(param.value, ArchivedVCardParameterValue::Null) { + out.write_atomic("=")?; + } + last_param = Some(¶m.name); + } + + match ¶m.value { + ArchivedVCardParameterValue::Text(v) => { + write_param_value(out, v)?; + } + ArchivedVCardParameterValue::Integer(i) => { + write!(out, "{}", i)?; + } + ArchivedVCardParameterValue::Timestamp(v) => { + write!(out, "{}", Timestamp(v.to_native()))?; + } + ArchivedVCardParameterValue::Bool(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + } + ArchivedVCardParameterValue::ValueType(v) => { + if types.is_none() { + types = Some(v); + } + write_param_value(out, v.as_str())?; + } + ArchivedVCardParameterValue::Type(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedVCardParameterValue::Calscale(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedVCardParameterValue::Level(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedVCardParameterValue::Phonetic(v) => { + write_param_value(out, v.as_str())?; + } + ArchivedVCardParameterValue::Jscomps(v) => { + out.write_atomic("\"")?; + write_jscomps(out, v)?; + out.write_atomic("\"")?; + last_param = None; + } + ArchivedVCardParameterValue::Null => { + last_param = None; + } + } + } + + if !is_v4 { + if let Some(data) = self.values.iter().find_map(|v| match v { + ArchivedVCardValue::Binary(data) => Some(data), + _ => None, + }) { + out.write_atomic(";ENCODING=b")?; + + if let Some(media_type) = data.content_type.as_deref() + && !self + .params + .iter() + .any(|param| param.name == VCardParameterName::Type) + && let Some(token) = legacy_media_type(self.name.as_str(), media_type) + { + out.write_atomic(";TYPE=")?; + out.write_atomic(&token)?; + } + } + + if self.values.iter().any(|v| match v { + ArchivedVCardValue::Text(s) => !s.is_ascii(), + ArchivedVCardValue::Component(items) => items.iter().any(|s| !s.is_ascii()), + _ => false, + }) { + out.write_atomic(";CHARSET=UTF-8")?; + } + } + + out.write_atomic(":")?; + + if with_value { + let (default_type, value_separator) = self.name.default_types(); + let default_type = default_type.unwrap_vcard(); + + let mut separator = ";"; + let mut escape_semicolon = + matches!(types.unwrap_or(&default_type), ArchivedVCardValueType::Text); + let mut escape_comma = escape_semicolon; + + match value_separator { + ValueSeparator::Comma => { + escape_comma = true; + separator = ","; + } + ValueSeparator::Semicolon => escape_semicolon = true, + ValueSeparator::SemicolonAndComma => { + escape_semicolon = true; + escape_comma = true; + } + _ => {} + } + + for (pos, value) in self.values.iter().enumerate() { + if pos > 0 { + out.write_atomic(separator)?; + } + + match value { + ArchivedVCardValue::Text(v) => { + write_text(out, v, escape_semicolon, escape_comma)?; + } + ArchivedVCardValue::Component(v) => { + for (pos, item) in v.iter().enumerate() { + if pos > 0 { + out.write_atomic(",")?; + } + write_text(out, item, true, true)?; + } + } + ArchivedVCardValue::Integer(v) => { + write!(out, "{v}")?; + } + ArchivedVCardValue::Float(v) => { + write!(out, "{v}")?; + } + ArchivedVCardValue::Boolean(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + } + ArchivedVCardValue::PartialDateTime(v) => { + let typ = if pos == 0 { + types + } else { + self.parameters(&VCardParameterName::Value) + .nth(pos) + .and_then(|v| v.as_value_type()) + .and_then(|v| v.iana().copied()) + } + .unwrap_or(&default_type); + if is_v4 { + v.format_as_vcard(out, typ)?; + } else { + v.format_as_legacy_vcard(out, typ)?; + } + } + ArchivedVCardValue::Binary(v) => { + if is_v4 { + let media_type = v.content_type.as_deref().unwrap_or_default(); + out.write_str("data:")?; + out.write_str(media_type)?; + out.write_str(";")?; + out.write_atomic("base64\\,")?; + } + write_bytes(out, &v.data)?; + } + ArchivedVCardValue::Sex(v) => { + out.write_atomic(v.as_str())?; + } + ArchivedVCardValue::GramGender(v) => { + out.write_atomic(v.as_str())?; + } + ArchivedVCardValue::Kind(v) => { + out.write_atomic(v.as_str())?; + } + } + } + } + out.end_line() + } +} + +impl crate::common::ArchivedPartialDateTime { + pub fn format_as_vcard( + &self, + out: &mut impl Write, + fmt: &ArchivedVCardValueType, + ) -> std::fmt::Result { + use ArchivedVCardValueType; + use rkyv::option::ArchivedOption; + + if matches!(fmt, ArchivedVCardValueType::Timestamp) { + write!( + out, + "{:04}{:02}{:02}T{:02}{:02}{:02}", + self.year.as_ref().map(u16::from).unwrap_or_default(), + self.month.as_ref().copied().unwrap_or_default(), + self.day.as_ref().copied().unwrap_or_default(), + self.hour.as_ref().copied().unwrap_or_default(), + self.minute.as_ref().copied().unwrap_or_default(), + self.second.as_ref().copied().unwrap_or_default() + )?; + + if let Some(tz_hour) = self.tz_hour.as_ref().copied() { + let tz_minute = self.tz_minute.as_ref().copied().unwrap_or_default(); + if tz_hour == 0 && tz_minute == 0 { + write!(out, "Z")?; + } else { + write!( + out, + "{}{:02}", + if self.tz_minus { "-" } else { "+" }, + tz_hour, + )?; + + if let Some(tz_minute) = self.tz_minute.as_ref() { + write!(out, "{:02}", tz_minute)?; + } + } + } + Ok(()) + } else { + let missing_time = + self.hour.is_none() && self.minute.is_none() && self.second.is_none(); + let missing_tz = self.tz_hour.is_none(); + + if matches!( + fmt, + ArchivedVCardValueType::Date + | ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + ) { + match (self.year, self.month, self.day) { + ( + ArchivedOption::Some(year), + ArchivedOption::Some(month), + ArchivedOption::Some(day), + ) => { + write!(out, "{:04}{:02}{:02}", year, month, day)?; + } + ( + ArchivedOption::Some(year), + ArchivedOption::Some(month), + ArchivedOption::None, + ) => { + if missing_time && missing_tz { + write!(out, "{:04}-{:02}", year, month)?; + } else { + write!(out, "{:04}{:02}", year, month)?; + } + } + ( + ArchivedOption::None, + ArchivedOption::Some(month), + ArchivedOption::Some(day), + ) => { + write!(out, "--{:02}{:02}", month, day)?; + } + (ArchivedOption::None, ArchivedOption::None, ArchivedOption::Some(day)) => { + write!(out, "---{:02}", day)?; + } + (ArchivedOption::Some(year), ArchivedOption::None, ArchivedOption::None) => { + write!(out, "{:04}", year)?; + } + (ArchivedOption::None, ArchivedOption::Some(month), ArchivedOption::None) => { + write!(out, "--{month}")?; + } + _ => {} + } + } + + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + | ArchivedVCardValueType::Time + ) && !missing_time + { + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime | ArchivedVCardValueType::DateTime + ) { + write!(out, "T")?; + } + let mut last_is_some = false; + for value in [&self.hour, &self.minute, &self.second].iter() { + if let ArchivedOption::Some(value) = value { + write!(out, "{:02}", value)?; + last_is_some = true; + } else if !last_is_some { + write!(out, "-")?; + } + } + } + + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + | ArchivedVCardValueType::Time + | ArchivedVCardValueType::UtcOffset + ) { + match (self.tz_hour.as_ref(), self.tz_minute.as_ref()) { + (Some(0), Some(0)) | (Some(0), _) => { + write!(out, "Z")?; + } + (Some(hour), Some(minute)) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}{minute:02}")?; + } + (Some(hour), None) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}")?; + } + _ => {} + } + } + + Ok(()) + } + } + + pub fn format_as_legacy_vcard( + &self, + out: &mut impl Write, + fmt: &ArchivedVCardValueType, + ) -> std::fmt::Result { + if matches!(fmt, ArchivedVCardValueType::Timestamp) { + write!( + out, + "{:04}{:02}{:02}", + self.year.as_ref().map(u16::from).unwrap_or_default(), + self.month.as_ref().copied().unwrap_or_default(), + self.day.as_ref().copied().unwrap_or_default(), + )?; + + if self.hour.is_some() { + write!( + out, + "T{:02}{:02}{:02}", + self.hour.as_ref().copied().unwrap_or_default(), + self.minute.as_ref().copied().unwrap_or_default(), + self.second.as_ref().copied().unwrap_or_default() + )?; + } + + if let Some(tz_hour) = self.tz_hour.as_ref().copied() { + let tz_minute = self.tz_minute.as_ref().copied().unwrap_or_default(); + if tz_hour == 0 && tz_minute == 0 { + write!(out, "Z")?; + } else { + write!( + out, + "{}{:02}", + if self.tz_minus { "-" } else { "+" }, + tz_hour, + )?; + + if let Some(tz_minute) = self.tz_minute.as_ref().copied() { + write!(out, "{:02}", tz_minute)?; + } + } + } + Ok(()) + } else { + let missing_time = + self.hour.is_none() && self.minute.is_none() && self.second.is_none(); + + if matches!( + fmt, + ArchivedVCardValueType::Date + | ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + ) { + match (self.year.as_ref(), self.month.as_ref(), self.day.as_ref()) { + (Some(year), Some(month), Some(day)) => { + write!(out, "{:04}-{:02}-{:02}", year, month, day)?; + } + (Some(year), Some(month), None) => { + write!(out, "{:04}-{:02}", year, month)?; + } + (None, Some(month), Some(day)) => { + write!(out, "--{:02}-{:02}", month, day)?; + } + (None, None, Some(day)) => { + write!(out, "---{:02}", day)?; + } + (Some(year), None, None) => { + write!(out, "{:04}", year)?; + } + (None, Some(month), None) => { + write!(out, "--{month}")?; + } + _ => {} + } + } + + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + | ArchivedVCardValueType::Time + ) && !missing_time + { + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime | ArchivedVCardValueType::DateTime + ) { + write!(out, "T")?; + } + let mut last_is_some = false; + for value in [&self.hour, &self.minute, &self.second].iter() { + if let Some(value) = value.as_ref() { + if last_is_some { + write!(out, ":")?; + } + write!(out, "{:02}", value)?; + last_is_some = true; + } else if !last_is_some { + write!(out, "-")?; + } + } + } + + if matches!( + fmt, + ArchivedVCardValueType::DateAndOrTime + | ArchivedVCardValueType::DateTime + | ArchivedVCardValueType::Time + | ArchivedVCardValueType::UtcOffset + ) { + match (self.tz_hour.as_ref(), self.tz_minute.as_ref()) { + (Some(0), Some(0)) | (Some(0), _) => { + write!(out, "Z")?; + } + (Some(hour), Some(minute)) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}:{minute:02}")?; + } + (Some(hour), None) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}")?; + } + _ => {} + } + } + + Ok(()) + } + } +} + +impl Display for ArchivedVCard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.write_to(f, self.version().unwrap_or_default()) + } +} + +pub(crate) fn write_jscomps( + out: &mut impl LineWriter, + values: &[ArchivedJscomp], +) -> std::fmt::Result { + for (pos, item) in values.iter().enumerate() { + if pos > 0 { + out.write_atomic(";")?; + } + match item { + ArchivedJscomp::Entry { position, value } => { + write!(out, "{position}")?; + if *value > 0 { + write!(out, ",{value}")?; + } + } + ArchivedJscomp::Separator(s) => { + if !s.is_empty() { + out.write_atomic("s,")?; + + for ch in s.chars() { + match ch { + '\\' => out.write_atomic("\\\\")?, + ',' => out.write_atomic("\\,")?, + ':' => out.write_atomic("\\:")?, + '=' => out.write_atomic("\\=")?, + ';' => out.write_atomic("\\;")?, + '"' => out.write_atomic("\\\"")?, + '\r' | '\n' => {} + _ => out.write_char(ch)?, + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::{ + Entry, Parser, + vcard::{ArchivedVCard, VCardVersion}, + }; + + #[test] + fn archived_v3_emits_charset_for_non_ascii() { + let input = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:José\r\nEND:VCARD\r\n".to_string(); + let mut parser = Parser::new(&input); + let Entry::VCard(vcard) = parser.entry() else { + panic!("expected vcard"); + }; + + let mut owned = String::new(); + vcard.write_to(&mut owned, VCardVersion::V3_0).unwrap(); + + let bytes = rkyv::to_bytes::(&vcard).unwrap(); + let archived = rkyv::access::(&bytes).unwrap(); + let mut archived_out = String::new(); + archived + .write_to(&mut archived_out, VCardVersion::V3_0) + .unwrap(); + + assert!( + owned.contains(";CHARSET=UTF-8"), + "owned missing charset: {owned}" + ); + assert!( + archived_out.contains(";CHARSET=UTF-8"), + "archived missing charset: {archived_out}" + ); + assert_eq!(owned, archived_out); + } +} diff --git a/vendor/calcard/src/vcard/types.rs b/vendor/calcard/src/vcard/types.rs new file mode 100644 index 00000000..fe39ca4f --- /dev/null +++ b/vendor/calcard/src/vcard/types.rs @@ -0,0 +1,608 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use std::borrow::Cow; + +use super::*; + +impl IanaParse for VCardProperty { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "BEGIN" => VCardProperty::Begin, + "END" => VCardProperty::End, + "SOURCE" => VCardProperty::Source, + "KIND" => VCardProperty::Kind, + "XML" => VCardProperty::Xml, + "FN" => VCardProperty::Fn, + "N" => VCardProperty::N, + "NICKNAME" => VCardProperty::Nickname, + "PHOTO" => VCardProperty::Photo, + "BDAY" => VCardProperty::Bday, + "ANNIVERSARY" => VCardProperty::Anniversary, + "GENDER" => VCardProperty::Gender, + "ADR" => VCardProperty::Adr, + "TEL" => VCardProperty::Tel, + "EMAIL" => VCardProperty::Email, + "IMPP" => VCardProperty::Impp, + "LANG" => VCardProperty::Lang, + "TZ" => VCardProperty::Tz, + "GEO" => VCardProperty::Geo, + "TITLE" => VCardProperty::Title, + "ROLE" => VCardProperty::Role, + "LOGO" => VCardProperty::Logo, + "ORG" => VCardProperty::Org, + "MEMBER" => VCardProperty::Member, + "RELATED" => VCardProperty::Related, + "CATEGORIES" => VCardProperty::Categories, + "NOTE" => VCardProperty::Note, + "PRODID" => VCardProperty::Prodid, + "REV" => VCardProperty::Rev, + "SOUND" => VCardProperty::Sound, + "UID" => VCardProperty::Uid, + "CLIENTPIDMAP" => VCardProperty::Clientpidmap, + "URL" => VCardProperty::Url, + "VERSION" => VCardProperty::Version, + "KEY" => VCardProperty::Key, + "FBURL" => VCardProperty::Fburl, + "CALADRURI" => VCardProperty::Caladruri, + "CALURI" => VCardProperty::Caluri, + "BIRTHPLACE" => VCardProperty::Birthplace, + "DEATHPLACE" => VCardProperty::Deathplace, + "DEATHDATE" => VCardProperty::Deathdate, + "EXPERTISE" => VCardProperty::Expertise, + "HOBBY" => VCardProperty::Hobby, + "INTEREST" => VCardProperty::Interest, + "ORG-DIRECTORY" => VCardProperty::OrgDirectory, + "CONTACT-URI" => VCardProperty::ContactUri, + "CREATED" => VCardProperty::Created, + "GRAMGENDER" => VCardProperty::Gramgender, + "LANGUAGE" => VCardProperty::Language, + "PRONOUNS" => VCardProperty::Pronouns, + "SOCIALPROFILE" => VCardProperty::Socialprofile, + "JSPROP" => VCardProperty::Jsprop, + ) + } +} + +impl VCardProperty { + pub fn as_str(&self) -> &str { + match self { + VCardProperty::Source => "SOURCE", + VCardProperty::Kind => "KIND", + VCardProperty::Xml => "XML", + VCardProperty::Fn => "FN", + VCardProperty::N => "N", + VCardProperty::Nickname => "NICKNAME", + VCardProperty::Photo => "PHOTO", + VCardProperty::Bday => "BDAY", + VCardProperty::Anniversary => "ANNIVERSARY", + VCardProperty::Gender => "GENDER", + VCardProperty::Adr => "ADR", + VCardProperty::Tel => "TEL", + VCardProperty::Email => "EMAIL", + VCardProperty::Impp => "IMPP", + VCardProperty::Lang => "LANG", + VCardProperty::Tz => "TZ", + VCardProperty::Geo => "GEO", + VCardProperty::Title => "TITLE", + VCardProperty::Role => "ROLE", + VCardProperty::Logo => "LOGO", + VCardProperty::Org => "ORG", + VCardProperty::Member => "MEMBER", + VCardProperty::Related => "RELATED", + VCardProperty::Categories => "CATEGORIES", + VCardProperty::Note => "NOTE", + VCardProperty::Prodid => "PRODID", + VCardProperty::Rev => "REV", + VCardProperty::Sound => "SOUND", + VCardProperty::Uid => "UID", + VCardProperty::Clientpidmap => "CLIENTPIDMAP", + VCardProperty::Url => "URL", + VCardProperty::Version => "VERSION", + VCardProperty::Key => "KEY", + VCardProperty::Fburl => "FBURL", + VCardProperty::Caladruri => "CALADRURI", + VCardProperty::Caluri => "CALURI", + VCardProperty::Birthplace => "BIRTHPLACE", + VCardProperty::Deathplace => "DEATHPLACE", + VCardProperty::Deathdate => "DEATHDATE", + VCardProperty::Expertise => "EXPERTISE", + VCardProperty::Hobby => "HOBBY", + VCardProperty::Interest => "INTEREST", + VCardProperty::OrgDirectory => "ORG-DIRECTORY", + VCardProperty::ContactUri => "CONTACT-URI", + VCardProperty::Created => "CREATED", + VCardProperty::Gramgender => "GRAMGENDER", + VCardProperty::Language => "LANGUAGE", + VCardProperty::Pronouns => "PRONOUNS", + VCardProperty::Socialprofile => "SOCIALPROFILE", + VCardProperty::Jsprop => "JSPROP", + VCardProperty::Begin => "BEGIN", + VCardProperty::End => "END", + VCardProperty::Other(v) => v.as_str(), + } + } +} + +impl VCardProperty { + // Returns the default value type and whether the property is multi-valued. + pub(crate) fn default_types(&self) -> (ValueType, ValueSeparator) { + match self { + VCardProperty::Source => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Kind => (ValueType::Kind, ValueSeparator::None), + VCardProperty::Xml => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Fn => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::N => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::SemicolonAndComma, + ), + VCardProperty::Nickname => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::Comma, + ), + VCardProperty::Photo => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Bday => ( + ValueType::Vcard(VCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + VCardProperty::Anniversary => ( + ValueType::Vcard(VCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + VCardProperty::Gender => (ValueType::Sex, ValueSeparator::Semicolon), + VCardProperty::Adr => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::SemicolonAndComma, + ), + VCardProperty::Tel => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Email => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Impp => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Lang => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Tz => ( + ValueType::Vcard(VCardValueType::UtcOffset), + ValueSeparator::None, + ), + VCardProperty::Geo => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Title => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Role => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Logo => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Org => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::Semicolon, + ), + VCardProperty::Member => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Related => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Categories => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::Comma, + ), + VCardProperty::Note => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Prodid => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Rev => ( + ValueType::Vcard(VCardValueType::Timestamp), + ValueSeparator::None, + ), + VCardProperty::Sound => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Uid => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Clientpidmap => ( + ValueType::Vcard(VCardValueType::Text), + ValueSeparator::Semicolon, + ), + VCardProperty::Url => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Version => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Key => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Fburl => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Caladruri => { + (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None) + } + VCardProperty::Caluri => (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None), + VCardProperty::Birthplace => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Deathplace => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Deathdate => ( + ValueType::Vcard(VCardValueType::DateAndOrTime), + ValueSeparator::None, + ), + VCardProperty::Expertise => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Hobby => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Interest => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::OrgDirectory => { + (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None) + } + VCardProperty::ContactUri => { + (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None) + } + VCardProperty::Created => ( + ValueType::Vcard(VCardValueType::Timestamp), + ValueSeparator::None, + ), + VCardProperty::Gramgender => (ValueType::GramGender, ValueSeparator::None), + VCardProperty::Language => ( + ValueType::Vcard(VCardValueType::LanguageTag), + ValueSeparator::None, + ), + VCardProperty::Pronouns => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Socialprofile => { + (ValueType::Vcard(VCardValueType::Uri), ValueSeparator::None) + } + VCardProperty::Jsprop => (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None), + VCardProperty::Other(_) => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::None) + } + VCardProperty::Begin | VCardProperty::End => { + (ValueType::Vcard(VCardValueType::Text), ValueSeparator::Skip) + } + } + } +} + +impl Eq for VCardValue {} + +impl IanaString for VCardValueType { + fn as_str(&self) -> &'static str { + match self { + VCardValueType::Boolean => "BOOLEAN", + VCardValueType::Date => "DATE", + VCardValueType::DateAndOrTime => "DATE-AND-OR-TIME", + VCardValueType::DateTime => "DATE-TIME", + VCardValueType::Float => "FLOAT", + VCardValueType::Integer => "INTEGER", + VCardValueType::LanguageTag => "LANGUAGE-TAG", + VCardValueType::Text => "TEXT", + VCardValueType::Time => "TIME", + VCardValueType::Timestamp => "TIMESTAMP", + VCardValueType::Uri => "URI", + VCardValueType::UtcOffset => "UTC-OFFSET", + } + } +} + +impl IanaParse for VCardValueType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "BOOLEAN" => VCardValueType::Boolean, + "DATE" => VCardValueType::Date, + "DATE-AND-OR-TIME" => VCardValueType::DateAndOrTime, + "DATE-TIME" => VCardValueType::DateTime, + "FLOAT" => VCardValueType::Float, + "INTEGER" => VCardValueType::Integer, + "LANGUAGE-TAG" => VCardValueType::LanguageTag, + "TEXT" => VCardValueType::Text, + "TIME" => VCardValueType::Time, + "TIMESTAMP" => VCardValueType::Timestamp, + "URI" => VCardValueType::Uri, + "UTC-OFFSET" => VCardValueType::UtcOffset, + ) + } +} + +impl IanaParse for VCardLevel { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "beginner" => VCardLevel::Beginner, + "average" => VCardLevel::Average, + "expert" => VCardLevel::Expert, + "high" => VCardLevel::High, + "medium" => VCardLevel::Medium, + "low" => VCardLevel::Low, + ) + } +} + +impl IanaString for VCardLevel { + fn as_str(&self) -> &'static str { + match self { + VCardLevel::Beginner => "BEGINNER", + VCardLevel::Average => "AVERAGE", + VCardLevel::Expert => "EXPERT", + VCardLevel::High => "HIGH", + VCardLevel::Medium => "MEDIUM", + VCardLevel::Low => "LOW", + } + } +} + +impl IanaParse for VCardPhonetic { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "ipa" => VCardPhonetic::Ipa, + "jyut" => VCardPhonetic::Jyut, + "piny" => VCardPhonetic::Piny, + "script" => VCardPhonetic::Script, + ) + } +} + +impl IanaString for VCardPhonetic { + fn as_str(&self) -> &'static str { + match self { + VCardPhonetic::Ipa => "IPA", + VCardPhonetic::Jyut => "JYUT", + VCardPhonetic::Piny => "PINY", + VCardPhonetic::Script => "SCRIPT", + } + } +} + +impl IanaParse for VCardType { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "work" => VCardType::Work, + "home" => VCardType::Home, + "billing" => VCardType::Billing, + "delivery" => VCardType::Delivery, + "contact" => VCardType::Contact, + "acquaintance" => VCardType::Acquaintance, + "friend" => VCardType::Friend, + "met" => VCardType::Met, + "co-worker" => VCardType::CoWorker, + "colleague" => VCardType::Colleague, + "co-resident" => VCardType::CoResident, + "neighbor" => VCardType::Neighbor, + "child" => VCardType::Child, + "parent" => VCardType::Parent, + "sibling" => VCardType::Sibling, + "spouse" => VCardType::Spouse, + "kin" => VCardType::Kin, + "muse" => VCardType::Muse, + "crush" => VCardType::Crush, + "date" => VCardType::Date, + "sweetheart" => VCardType::Sweetheart, + "me" => VCardType::Me, + "agent" => VCardType::Agent, + "emergency" => VCardType::Emergency, + "text" => VCardType::Text, + "voice" => VCardType::Voice, + "fax" => VCardType::Fax, + "cell" => VCardType::Cell, + "video" => VCardType::Video, + "pager" => VCardType::Pager, + "textphone" => VCardType::Textphone, + "main-number" => VCardType::MainNumber, + ) + } +} + +impl IanaString for VCardType { + fn as_str(&self) -> &'static str { + match self { + VCardType::Work => "WORK", + VCardType::Home => "HOME", + VCardType::Billing => "BILLING", + VCardType::Delivery => "DELIVERY", + VCardType::Contact => "CONTACT", + VCardType::Acquaintance => "ACQUAINTANCE", + VCardType::Friend => "FRIEND", + VCardType::Met => "MET", + VCardType::CoWorker => "CO-WORKER", + VCardType::Colleague => "COLLEAGUE", + VCardType::CoResident => "CO-RESIDENT", + VCardType::Neighbor => "NEIGHBOR", + VCardType::Child => "CHILD", + VCardType::Parent => "PARENT", + VCardType::Sibling => "SIBLING", + VCardType::Spouse => "SPOUSE", + VCardType::Kin => "KIN", + VCardType::Muse => "MUSE", + VCardType::Crush => "CRUSH", + VCardType::Date => "DATE", + VCardType::Sweetheart => "SWEETHEART", + VCardType::Me => "ME", + VCardType::Agent => "AGENT", + VCardType::Emergency => "EMERGENCY", + VCardType::Text => "TEXT", + VCardType::Voice => "VOICE", + VCardType::Fax => "FAX", + VCardType::Cell => "CELL", + VCardType::Video => "VIDEO", + VCardType::Pager => "PAGER", + VCardType::Textphone => "TEXTPHONE", + VCardType::MainNumber => "MAIN-NUMBER", + } + } +} + +impl IanaParse for VCardGramGender { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "animate" => VCardGramGender::Animate, + "common" => VCardGramGender::Common, + "feminine" => VCardGramGender::Feminine, + "inanimate" => VCardGramGender::Inanimate, + "masculine" => VCardGramGender::Masculine, + "neuter" => VCardGramGender::Neuter, + ) + } +} + +impl IanaString for VCardGramGender { + fn as_str(&self) -> &'static str { + match self { + VCardGramGender::Animate => "ANIMATE", + VCardGramGender::Common => "COMMON", + VCardGramGender::Feminine => "FEMININE", + VCardGramGender::Inanimate => "INANIMATE", + VCardGramGender::Masculine => "MASCULINE", + VCardGramGender::Neuter => "NEUTER", + } + } +} + +impl IanaParse for VCardSex { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "M" => VCardSex::Male, + "F" => VCardSex::Female, + "O" => VCardSex::Other, + "N" => VCardSex::NoneOrNotApplicable, + "U" => VCardSex::Unknown, + ) + } +} + +impl IanaString for VCardSex { + fn as_str(&self) -> &'static str { + match self { + VCardSex::Male => "M", + VCardSex::Female => "F", + VCardSex::Other => "O", + VCardSex::NoneOrNotApplicable => "N", + VCardSex::Unknown => "U", + } + } +} + +impl IanaParse for VCardKind { + fn parse(value: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(value, + "individual" => VCardKind::Individual, + "group" => VCardKind::Group, + "org" => VCardKind::Org, + "location" => VCardKind::Location, + "application" => VCardKind::Application, + "device" => VCardKind::Device, + ) + } +} + +impl IanaString for VCardKind { + fn as_str(&self) -> &'static str { + match self { + VCardKind::Individual => "INDIVIDUAL", + VCardKind::Group => "GROUP", + VCardKind::Org => "ORG", + VCardKind::Location => "LOCATION", + VCardKind::Application => "APPLICATION", + VCardKind::Device => "DEVICE", + } + } +} + +impl VCardParameterName { + pub fn try_parse(input: &[u8]) -> Option { + hashify::tiny_map_ignore_case!(input, + b"LANGUAGE" => VCardParameterName::Language, + b"VALUE" => VCardParameterName::Value, + b"PREF" => VCardParameterName::Pref, + b"ALTID" => VCardParameterName::Altid, + b"PID" => VCardParameterName::Pid, + b"TYPE" => VCardParameterName::Type, + b"MEDIATYPE" => VCardParameterName::Mediatype, + b"CALSCALE" => VCardParameterName::Calscale, + b"SORT-AS" => VCardParameterName::SortAs, + b"GEO" => VCardParameterName::Geo, + b"TZ" => VCardParameterName::Tz, + b"INDEX" => VCardParameterName::Index, + b"LEVEL" => VCardParameterName::Level, + b"GROUP" => VCardParameterName::Group, + b"CC" => VCardParameterName::Cc, + b"AUTHOR" => VCardParameterName::Author, + b"AUTHOR-NAME" => VCardParameterName::AuthorName, + b"CREATED" => VCardParameterName::Created, + b"DERIVED" => VCardParameterName::Derived, + b"LABEL" => VCardParameterName::Label, + b"PHONETIC" => VCardParameterName::Phonetic, + b"PROP-ID" => VCardParameterName::PropId, + b"SCRIPT" => VCardParameterName::Script, + b"SERVICE-TYPE" => VCardParameterName::ServiceType, + b"USERNAME" => VCardParameterName::Username, + b"JSPTR" => VCardParameterName::Jsptr, + b"JSCOMPS" => VCardParameterName::Jscomps, + ) + } + + pub fn parse(input: &str) -> Self { + Self::try_parse(input.as_bytes()).unwrap_or_else(|| VCardParameterName::Other(input.into())) + } + + pub fn as_str(&self) -> &str { + match self { + VCardParameterName::Language => "LANGUAGE", + VCardParameterName::Value => "VALUE", + VCardParameterName::Pref => "PREF", + VCardParameterName::Altid => "ALTID", + VCardParameterName::Pid => "PID", + VCardParameterName::Type => "TYPE", + VCardParameterName::Mediatype => "MEDIATYPE", + VCardParameterName::Calscale => "CALSCALE", + VCardParameterName::SortAs => "SORT-AS", + VCardParameterName::Geo => "GEO", + VCardParameterName::Tz => "TZ", + VCardParameterName::Index => "INDEX", + VCardParameterName::Level => "LEVEL", + VCardParameterName::Group => "GROUP", + VCardParameterName::Cc => "CC", + VCardParameterName::Author => "AUTHOR", + VCardParameterName::AuthorName => "AUTHOR-NAME", + VCardParameterName::Created => "CREATED", + VCardParameterName::Derived => "DERIVED", + VCardParameterName::Label => "LABEL", + VCardParameterName::Phonetic => "PHONETIC", + VCardParameterName::PropId => "PROP-ID", + VCardParameterName::Script => "SCRIPT", + VCardParameterName::ServiceType => "SERVICE-TYPE", + VCardParameterName::Username => "USERNAME", + VCardParameterName::Jsptr => "JSPTR", + VCardParameterName::Jscomps => "JSCOMPS", + VCardParameterName::Other(name) => name, + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + VCardParameterName::Language => "LANGUAGE".into(), + VCardParameterName::Value => "VALUE".into(), + VCardParameterName::Pref => "PREF".into(), + VCardParameterName::Altid => "ALTID".into(), + VCardParameterName::Pid => "PID".into(), + VCardParameterName::Type => "TYPE".into(), + VCardParameterName::Mediatype => "MEDIATYPE".into(), + VCardParameterName::Calscale => "CALSCALE".into(), + VCardParameterName::SortAs => "SORT-AS".into(), + VCardParameterName::Geo => "GEO".into(), + VCardParameterName::Tz => "TZ".into(), + VCardParameterName::Index => "INDEX".into(), + VCardParameterName::Level => "LEVEL".into(), + VCardParameterName::Group => "GROUP".into(), + VCardParameterName::Cc => "CC".into(), + VCardParameterName::Author => "AUTHOR".into(), + VCardParameterName::AuthorName => "AUTHOR-NAME".into(), + VCardParameterName::Created => "CREATED".into(), + VCardParameterName::Derived => "DERIVED".into(), + VCardParameterName::Label => "LABEL".into(), + VCardParameterName::Phonetic => "PHONETIC".into(), + VCardParameterName::PropId => "PROP-ID".into(), + VCardParameterName::Script => "SCRIPT".into(), + VCardParameterName::ServiceType => "SERVICE-TYPE".into(), + VCardParameterName::Username => "USERNAME".into(), + VCardParameterName::Jsptr => "JSPTR".into(), + VCardParameterName::Jscomps => "JSCOMPS".into(), + VCardParameterName::Other(name) => name.into(), + } + } +} + +impl VCardVersion { + pub fn try_parse(input: &str) -> Option { + hashify::tiny_map!(input.as_bytes(), + b"4.0" => VCardVersion::V4_0, + b"3.0" => VCardVersion::V3_0, + b"2.1" => VCardVersion::V2_1, + b"2.0" => VCardVersion::V2_0, + ) + } +} diff --git a/vendor/calcard/src/vcard/utils.rs b/vendor/calcard/src/vcard/utils.rs new file mode 100644 index 00000000..272c1ce1 --- /dev/null +++ b/vendor/calcard/src/vcard/utils.rs @@ -0,0 +1,424 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{VCard, VCardEntry, VCardParameterName, VCardProperty, VCardValue, VCardVersion}; +use crate::{ + common::{ + CalendarScale, Data, IanaString, IanaType, PartialDateTime, + writer::{write_bytes, write_jscomps}, + }, + vcard::{ + Jscomp, VCardLevel, VCardParameter, VCardParameterValue, VCardPhonetic, VCardType, + VCardValueType, media_type::media_type_from_legacy, + }, +}; +use std::borrow::Cow; + +impl VCard { + pub fn uid(&self) -> Option<&str> { + self.property(&VCardProperty::Uid) + .and_then(|e| e.values.first()) + .and_then(|v| v.as_text()) + } + + pub fn property(&self, prop: &VCardProperty) -> Option<&VCardEntry> { + self.entries.iter().find(|entry| &entry.name == prop) + } + + pub fn properties<'x, 'y: 'x>( + &'x self, + prop: &'y VCardProperty, + ) -> impl Iterator + 'x { + self.entries.iter().filter(move |entry| &entry.name == prop) + } + + pub fn version(&self) -> Option { + self.entries + .iter() + .find(|e| e.name == VCardProperty::Version) + .and_then(|e| { + e.values + .first() + .and_then(|v| v.as_text()) + .and_then(VCardVersion::try_parse) + }) + } + + pub fn size(&self) -> usize { + self.entries.iter().map(|e| e.size()).sum() + } +} + +impl VCardEntry { + #[inline] + pub fn parameters( + &self, + prop: &VCardParameterName, + ) -> impl Iterator { + self.params.iter().filter_map(move |param| { + if ¶m.name == prop { + Some(¶m.value) + } else { + None + } + }) + } + + pub fn language(&self) -> Option<&str> { + self.parameters(&VCardParameterName::Language) + .find_map(|v| v.as_text()) + } + + pub fn alt_id(&self) -> Option<&str> { + self.parameters(&VCardParameterName::Altid) + .find_map(|v| v.as_text()) + } + + pub fn prop_id(&self) -> Option<&str> { + self.parameters(&VCardParameterName::PropId) + .find_map(|v| v.as_text()) + } + + pub fn phonetic_system(&self) -> Option> { + self.parameters(&VCardParameterName::Phonetic) + .find_map(|v| v.as_phonetic()) + } + + pub fn phonetic_script(&self) -> Option<&str> { + self.parameters(&VCardParameterName::Script) + .find_map(|v| v.as_text()) + } + + pub fn size(&self) -> usize { + self.group.as_ref().map_or(0, |g| g.len()) + + self.name.as_str().len() + + self.params.iter().map(|p| p.size()).sum::() + + self.values.iter().map(|v| v.size()).sum::() + } + + pub(crate) fn normalize_legacy_media_type(&mut self) { + if self + .values + .iter() + .any(|value| matches!(value, VCardValue::Binary(data) if data.content_type.is_none())) + { + let property = self.name.as_str(); + let media_type = + self.params + .iter() + .find_map(|param| match (¶m.name, ¶m.value) { + (VCardParameterName::Mediatype, VCardParameterValue::Text(text)) => { + Some(Cow::Owned(text.to_ascii_lowercase())) + } + (VCardParameterName::Type, VCardParameterValue::Text(text)) => { + media_type_from_legacy(property, text) + } + _ => None, + }); + + if let Some(media_type) = media_type { + for value in &mut self.values { + if let VCardValue::Binary(data) = value + && data.content_type.is_none() + { + data.content_type = Some(media_type.to_string()); + } + } + } + } + } +} + +impl VCardValue { + pub fn as_text(&self) -> Option<&str> { + match self { + VCardValue::Text(v) => v.as_str().into(), + VCardValue::Sex(v) => v.as_str().into(), + VCardValue::GramGender(v) => v.as_str().into(), + VCardValue::Kind(v) => v.as_str().into(), + VCardValue::Component(v) => v.first().map(|s| s.as_str()), + VCardValue::Integer(_) + | VCardValue::Float(_) + | VCardValue::Boolean(_) + | VCardValue::PartialDateTime(_) + | VCardValue::Binary(_) => None, + } + } + + pub fn into_text(self) -> Option> { + match self { + VCardValue::Text(s) => Some(Cow::Owned(s)), + VCardValue::Sex(v) => Some(Cow::Borrowed(v.as_str())), + VCardValue::GramGender(v) => Some(Cow::Borrowed(v.as_str())), + VCardValue::Kind(v) => Some(Cow::Borrowed(v.as_str())), + VCardValue::Component(v) => Some(Cow::Owned(v.join(","))), + VCardValue::Integer(_) + | VCardValue::Float(_) + | VCardValue::Boolean(_) + | VCardValue::PartialDateTime(_) + | VCardValue::Binary(_) => None, + } + } + + pub fn into_uri(self) -> Option { + match self { + VCardValue::Binary(v) => Some(v.to_unwrapped_string()), + VCardValue::Text(v) => Some(v), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + VCardValue::Integer(i) => Some(*i), + _ => None, + } + } + + pub fn as_float(&self) -> Option { + match self { + VCardValue::Float(f) => Some(*f), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option { + match self { + VCardValue::Boolean(b) => Some(*b), + _ => None, + } + } + + pub fn as_partial_date_time(&self) -> Option<&PartialDateTime> { + match self { + VCardValue::PartialDateTime(dt) => Some(dt), + _ => None, + } + } + + pub fn as_binary(&self) -> Option<&Data> { + match self { + VCardValue::Binary(d) => Some(d), + _ => None, + } + } + + pub fn size(&self) -> usize { + match self { + VCardValue::Text(v) => v.len(), + VCardValue::Sex(v) => v.as_str().len(), + VCardValue::GramGender(v) => v.as_str().len(), + VCardValue::Kind(v) => v.as_str().len(), + VCardValue::Component(v) => v.iter().map(|s| s.len()).sum::(), + VCardValue::Integer(_) => std::mem::size_of::(), + VCardValue::Float(_) => std::mem::size_of::(), + VCardValue::Boolean(_) => std::mem::size_of::(), + VCardValue::PartialDateTime(_) => std::mem::size_of::(), + VCardValue::Binary(d) => { + d.content_type.as_ref().map_or(0, |ct| ct.len()) + d.data.len() + } + } + } +} + +impl Data { + pub fn to_unwrapped_string(&self) -> String { + use std::fmt::Write; + let media_type = self.content_type.as_deref().unwrap_or_default(); + let mut out = String::with_capacity(self.data.len().div_ceil(4) + media_type.len() + 14); + let _ = write!(&mut out, "data:{media_type};base64,"); + let _ = write_bytes(&mut out, &self.data); + out + } +} + +impl VCardParameterValue { + pub fn into_text(self) -> Cow<'static, str> { + match self { + VCardParameterValue::Text(s) => Cow::Owned(s), + VCardParameterValue::ValueType(v) => Cow::Borrowed(v.as_str()), + VCardParameterValue::Type(v) => Cow::Borrowed(v.as_str()), + VCardParameterValue::Calscale(v) => Cow::Borrowed(v.as_str()), + VCardParameterValue::Level(v) => Cow::Borrowed(v.as_str()), + VCardParameterValue::Phonetic(v) => Cow::Borrowed(v.as_str()), + VCardParameterValue::Jscomps(v) => { + let mut jscomps = String::new(); + let _ = write_jscomps(&mut jscomps, &v); + Cow::Owned(jscomps) + } + VCardParameterValue::Integer(i) => Cow::Owned(i.to_string()), + VCardParameterValue::Timestamp(t) => Cow::Owned(t.to_string()), + VCardParameterValue::Bool(b) => Cow::Borrowed(if b { "true" } else { "false" }), + VCardParameterValue::Null => Cow::Borrowed(""), + } + } + + pub fn as_text(&self) -> Option<&str> { + match self { + VCardParameterValue::Text(v) => Some(v.as_str()), + VCardParameterValue::ValueType(v) => Some(v.as_str()), + VCardParameterValue::Type(v) => Some(v.as_str()), + VCardParameterValue::Calscale(v) => Some(v.as_str()), + VCardParameterValue::Level(v) => Some(v.as_str()), + VCardParameterValue::Phonetic(v) => Some(v.as_str()), + VCardParameterValue::Jscomps(_) + | VCardParameterValue::Integer(_) + | VCardParameterValue::Timestamp(_) + | VCardParameterValue::Bool(_) + | VCardParameterValue::Null => None, + } + } + + pub fn into_phonetic(self) -> Option> { + match self { + VCardParameterValue::Phonetic(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_phonetic(&self) -> Option> { + match self { + VCardParameterValue::Phonetic(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_calscale(self) -> Option> { + match self { + VCardParameterValue::Calscale(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_calscale(&self) -> Option> { + match self { + VCardParameterValue::Calscale(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_level(self) -> Option> { + match self { + VCardParameterValue::Level(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_level(&self) -> Option> { + match self { + VCardParameterValue::Level(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_type(self) -> Option> { + match self { + VCardParameterValue::Type(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_type(&self) -> Option> { + match self { + VCardParameterValue::Type(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_value_type(self) -> Option> { + match self { + VCardParameterValue::ValueType(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_value_type(&self) -> Option> { + match self { + VCardParameterValue::ValueType(v) => Some(IanaType::Iana(v)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_bool(&self) -> Option> { + match self { + VCardParameterValue::Bool(b) => Some(IanaType::Iana(*b)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn as_integer(&self) -> Option> { + match self { + VCardParameterValue::Integer(i) => Some(IanaType::Iana(*i)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_timestamp(self) -> Option> { + match self { + VCardParameterValue::Timestamp(t) => Some(IanaType::Iana(t)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v)), + _ => None, + } + } + + pub fn as_timestamp(&self) -> Option> { + match self { + VCardParameterValue::Timestamp(t) => Some(IanaType::Iana(*t)), + VCardParameterValue::Text(v) => Some(IanaType::Other(v.as_str())), + _ => None, + } + } + + pub fn into_jscomps(self) -> Option> { + match self { + VCardParameterValue::Jscomps(v) => Some(v), + _ => None, + } + } + + pub fn size(&self) -> usize { + match self { + VCardParameterValue::Text(v) => v.len(), + VCardParameterValue::ValueType(v) => v.as_str().len(), + VCardParameterValue::Type(v) => v.as_str().len(), + VCardParameterValue::Calscale(v) => v.as_str().len(), + VCardParameterValue::Level(v) => v.as_str().len(), + VCardParameterValue::Phonetic(v) => v.as_str().len(), + VCardParameterValue::Jscomps(v) => v.iter().map(|j| j.size()).sum::(), + VCardParameterValue::Integer(_) => std::mem::size_of::(), + VCardParameterValue::Timestamp(_) => std::mem::size_of::(), + VCardParameterValue::Bool(_) => std::mem::size_of::(), + VCardParameterValue::Null => 0, + } + } +} + +impl VCardParameter { + pub fn size(&self) -> usize { + self.name.as_str().len() + self.value.size() + } +} + +impl Jscomp { + pub fn size(&self) -> usize { + match self { + Jscomp::Entry { .. } => std::mem::size_of::(), + Jscomp::Separator(value) => value.len(), + } + } +} diff --git a/vendor/calcard/src/vcard/writer.rs b/vendor/calcard/src/vcard/writer.rs new file mode 100644 index 00000000..ff395b10 --- /dev/null +++ b/vendor/calcard/src/vcard/writer.rs @@ -0,0 +1,651 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use super::{PartialDateTime, VCard, VCardEntry, VCardValueType, VCardVersion}; +use crate::{ + common::{ + IanaString, + parser::Timestamp, + writer::{ + FoldingWriter, LineWriter, write_bytes, write_jscomps, write_param_value, write_text, + }, + }, + vcard::{ + VCardParameterName, VCardParameterValue, VCardProperty, VCardValue, ValueSeparator, + media_type::legacy_media_type, + }, +}; +use std::fmt::{Display, Write}; + +impl VCard { + pub fn write_to(&self, out: &mut impl Write, version: VCardVersion) -> std::fmt::Result { + write!(out, "BEGIN:VCARD\r\n")?; + write!(out, "VERSION:{version}\r\n")?; + let is_v4 = matches!(version, VCardVersion::V4_0); + for entry in &self.entries { + if !matches!( + entry.name, + VCardProperty::Begin | VCardProperty::End | VCardProperty::Version + ) { + entry.write_to(out, is_v4)?; + } + } + write!(out, "END:VCARD\r\n") + } +} + +impl VCardEntry { + pub fn write_to(&self, out: &mut impl Write, is_v4: bool) -> std::fmt::Result { + let mut folded = FoldingWriter::new(out); + let out = &mut folded; + + if let Some(group_name) = &self.group { + out.write_atomic(group_name)?; + out.write_atomic(".")?; + } + + out.write_atomic(self.name.as_str())?; + let mut types = None; + let mut last_param: Option<&VCardParameterName> = None; + + for param in &self.params { + if last_param.is_some_and(|last_param| last_param == ¶m.name) { + out.write_atomic(",")?; + } else { + out.write_atomic(";")?; + out.write_atomic(param.name.as_str())?; + if !matches!(param.value, VCardParameterValue::Null) { + out.write_atomic("=")?; + } + last_param = Some(¶m.name); + } + + match ¶m.value { + VCardParameterValue::Text(v) => { + write_param_value(out, v)?; + } + VCardParameterValue::Integer(i) => { + write!(out, "{i}")?; + } + VCardParameterValue::Timestamp(v) => { + write!(out, "{}", Timestamp(*v))?; + } + VCardParameterValue::Bool(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + } + VCardParameterValue::ValueType(v) => { + if types.is_none() { + types = Some(v); + } + write_param_value(out, v.as_str())?; + } + VCardParameterValue::Type(v) => { + write_param_value(out, v.as_str())?; + } + VCardParameterValue::Calscale(v) => { + write_param_value(out, v.as_str())?; + } + VCardParameterValue::Level(v) => { + write_param_value(out, v.as_str())?; + } + VCardParameterValue::Phonetic(v) => { + write_param_value(out, v.as_str())?; + } + VCardParameterValue::Jscomps(v) => { + out.write_atomic("\"")?; + write_jscomps(out, v)?; + out.write_atomic("\"")?; + last_param = None; + } + VCardParameterValue::Null => { + last_param = None; + } + } + } + + if !is_v4 { + if let Some(data) = self.values.iter().find_map(|v| match v { + VCardValue::Binary(data) => Some(data), + _ => None, + }) { + out.write_atomic(";ENCODING=b")?; + + if let Some(media_type) = data.content_type.as_deref() + && !self + .params + .iter() + .any(|param| param.name == VCardParameterName::Type) + && let Some(token) = legacy_media_type(self.name.as_str(), media_type) + { + out.write_atomic(";TYPE=")?; + out.write_atomic(&token)?; + } + } + + if self.values.iter().any(|v| match v { + VCardValue::Text(s) => !s.is_ascii(), + VCardValue::Component(items) => items.iter().any(|s| !s.is_ascii()), + _ => false, + }) { + out.write_atomic(";CHARSET=UTF-8")?; + } + } + + out.write_atomic(":")?; + + let (default_type, value_separator) = self.name.default_types(); + let default_type = default_type.unwrap_vcard(); + + let mut separator = ";"; + let mut escape_semicolon = matches!(types.unwrap_or(&default_type), VCardValueType::Text); + let mut escape_comma = escape_semicolon; + + match value_separator { + ValueSeparator::Comma => { + escape_comma = true; + separator = ","; + } + ValueSeparator::Semicolon => escape_semicolon = true, + ValueSeparator::SemicolonAndComma => { + escape_semicolon = true; + escape_comma = true; + } + _ => {} + } + + for (pos, value) in self.values.iter().enumerate() { + if pos > 0 { + out.write_atomic(separator)?; + } + + match value { + VCardValue::Text(v) => { + write_text(out, v, escape_semicolon, escape_comma)?; + } + VCardValue::Component(v) => { + for (pos, item) in v.iter().enumerate() { + if pos > 0 { + out.write_atomic(",")?; + } + write_text(out, item, true, true)?; + } + } + VCardValue::Integer(v) => { + write!(out, "{v}")?; + } + VCardValue::Float(v) => { + write!(out, "{v}")?; + } + VCardValue::Boolean(v) => { + out.write_atomic(if *v { "TRUE" } else { "FALSE" })?; + } + VCardValue::PartialDateTime(v) => { + let typ = if pos == 0 { + types + } else { + self.parameters(&VCardParameterName::Value) + .nth(pos) + .and_then(|v| v.as_value_type()) + .and_then(|v| v.iana().copied()) + } + .unwrap_or(&default_type); + if is_v4 { + v.format_as_vcard(out, typ)?; + } else { + v.format_as_legacy_vcard(out, typ)?; + } + } + VCardValue::Binary(v) => { + if is_v4 { + let media_type = v.content_type.as_deref().unwrap_or_default(); + out.write_str("data:")?; + out.write_str(media_type)?; + out.write_str(";")?; + out.write_atomic("base64\\,")?; + } + write_bytes(out, &v.data)?; + } + VCardValue::Sex(v) => { + out.write_atomic(v.as_str())?; + } + VCardValue::GramGender(v) => { + out.write_atomic(v.as_str())?; + } + VCardValue::Kind(v) => { + out.write_atomic(v.as_str())?; + } + } + } + + out.end_line() + } +} + +impl PartialDateTime { + pub fn format_as_vcard(&self, out: &mut impl Write, fmt: &VCardValueType) -> std::fmt::Result { + if matches!(fmt, VCardValueType::Timestamp) { + write!( + out, + "{:04}{:02}{:02}T{:02}{:02}{:02}", + self.year.unwrap_or_default(), + self.month.unwrap_or_default(), + self.day.unwrap_or_default(), + self.hour.unwrap_or_default(), + self.minute.unwrap_or_default(), + self.second.unwrap_or_default() + )?; + + if let Some(tz_hour) = self.tz_hour { + let tz_minute = self.tz_minute.unwrap_or_default(); + if tz_hour == 0 && tz_minute == 0 { + write!(out, "Z")?; + } else { + write!( + out, + "{}{:02}", + if self.tz_minus { "-" } else { "+" }, + tz_hour, + )?; + + if let Some(tz_minute) = self.tz_minute { + write!(out, "{:02}", tz_minute)?; + } + } + } + Ok(()) + } else { + let missing_time = + self.hour.is_none() && self.minute.is_none() && self.second.is_none(); + let missing_tz = self.tz_hour.is_none(); + + if matches!( + fmt, + VCardValueType::Date | VCardValueType::DateAndOrTime | VCardValueType::DateTime + ) { + match (self.year, self.month, self.day) { + (Some(year), Some(month), Some(day)) => { + write!(out, "{:04}{:02}{:02}", year, month, day)?; + } + (Some(year), Some(month), None) => { + if missing_time && missing_tz { + write!(out, "{:04}-{:02}", year, month)?; + } else { + write!(out, "{:04}{:02}", year, month)?; + } + } + (None, Some(month), Some(day)) => { + write!(out, "--{:02}{:02}", month, day)?; + } + (None, None, Some(day)) => { + write!(out, "---{:02}", day)?; + } + (Some(year), None, None) => { + write!(out, "{:04}", year)?; + } + (None, Some(month), None) => { + write!(out, "--{month}")?; + } + _ => {} + } + } + + if matches!( + fmt, + VCardValueType::DateAndOrTime | VCardValueType::DateTime | VCardValueType::Time + ) && !missing_time + { + if matches!( + fmt, + VCardValueType::DateAndOrTime | VCardValueType::DateTime + ) { + write!(out, "T")?; + } + let mut last_is_some = false; + for value in [&self.hour, &self.minute, &self.second].iter() { + if let Some(value) = value { + write!(out, "{:02}", value)?; + last_is_some = true; + } else if !last_is_some { + write!(out, "-")?; + } + } + } + + if matches!( + fmt, + VCardValueType::DateAndOrTime + | VCardValueType::DateTime + | VCardValueType::Time + | VCardValueType::UtcOffset + ) { + match (self.tz_hour, self.tz_minute) { + (Some(0), Some(0)) | (Some(0), _) => { + write!(out, "Z")?; + } + (Some(hour), Some(minute)) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}{minute:02}")?; + } + (Some(hour), None) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}")?; + } + _ => {} + } + } + + Ok(()) + } + } + + pub fn format_as_legacy_vcard( + &self, + out: &mut impl Write, + fmt: &VCardValueType, + ) -> std::fmt::Result { + if matches!(fmt, VCardValueType::Timestamp) { + write!( + out, + "{:04}{:02}{:02}", + self.year.unwrap_or_default(), + self.month.unwrap_or_default(), + self.day.unwrap_or_default(), + )?; + + if self.hour.is_some() { + write!( + out, + "T{:02}{:02}{:02}", + self.hour.unwrap_or_default(), + self.minute.unwrap_or_default(), + self.second.unwrap_or_default() + )?; + } + + if let Some(tz_hour) = self.tz_hour { + let tz_minute = self.tz_minute.unwrap_or_default(); + if tz_hour == 0 && tz_minute == 0 { + write!(out, "Z")?; + } else { + write!( + out, + "{}{:02}", + if self.tz_minus { "-" } else { "+" }, + tz_hour, + )?; + + if let Some(tz_minute) = self.tz_minute { + write!(out, "{:02}", tz_minute)?; + } + } + } + Ok(()) + } else { + let missing_time = + self.hour.is_none() && self.minute.is_none() && self.second.is_none(); + + if matches!( + fmt, + VCardValueType::Date | VCardValueType::DateAndOrTime | VCardValueType::DateTime + ) { + match (self.year, self.month, self.day) { + (Some(year), Some(month), Some(day)) => { + write!(out, "{:04}-{:02}-{:02}", year, month, day)?; + } + (Some(year), Some(month), None) => { + write!(out, "{:04}-{:02}", year, month)?; + } + (None, Some(month), Some(day)) => { + write!(out, "--{:02}-{:02}", month, day)?; + } + (None, None, Some(day)) => { + write!(out, "---{:02}", day)?; + } + (Some(year), None, None) => { + write!(out, "{:04}", year)?; + } + (None, Some(month), None) => { + write!(out, "--{month}")?; + } + _ => {} + } + } + + if matches!( + fmt, + VCardValueType::DateAndOrTime | VCardValueType::DateTime | VCardValueType::Time + ) && !missing_time + { + if matches!( + fmt, + VCardValueType::DateAndOrTime | VCardValueType::DateTime + ) { + write!(out, "T")?; + } + let mut last_is_some = false; + for value in [&self.hour, &self.minute, &self.second].iter() { + if let Some(value) = value { + if last_is_some { + write!(out, ":")?; + } + write!(out, "{:02}", value)?; + last_is_some = true; + } else if !last_is_some { + write!(out, "-")?; + } + } + } + + if matches!( + fmt, + VCardValueType::DateAndOrTime + | VCardValueType::DateTime + | VCardValueType::Time + | VCardValueType::UtcOffset + ) { + match (self.tz_hour, self.tz_minute) { + (Some(0), Some(0)) | (Some(0), _) => { + write!(out, "Z")?; + } + (Some(hour), Some(minute)) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}:{minute:02}")?; + } + (Some(hour), None) => { + if self.tz_minus { + write!(out, "-")?; + } else { + write!(out, "+")?; + } + write!(out, "{hour:02}")?; + } + _ => {} + } + } + + Ok(()) + } + } +} + +impl Display for VCard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.write_to(f, self.version().unwrap_or_default()) + } +} + +impl Display for VCardVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VCardVersion::V2_0 => write!(f, "2.0"), + VCardVersion::V2_1 => write!(f, "2.1"), + VCardVersion::V3_0 => write!(f, "3.0"), + VCardVersion::V4_0 => write!(f, "4.0"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + Entry, Parser, + vcard::{VCard, VCardVersion}, + }; + + fn parse(input: &str) -> VCard { + let mut parser = Parser::new(input); + let Entry::VCard(vcard) = parser.entry() else { + panic!("expected vcard for {input}"); + }; + vcard + } + + fn write(vcard: &VCard, version: VCardVersion) -> String { + let mut out = String::new(); + vcard.write_to(&mut out, version).unwrap(); + + #[cfg(feature = "rkyv")] + { + let bytes = rkyv::to_bytes::(vcard).unwrap(); + let archived = + rkyv::access::(&bytes).unwrap(); + let mut archived_out = String::new(); + archived.write_to(&mut archived_out, version).unwrap(); + assert_eq!(out, archived_out, "archived writer diverged at {version}"); + } + + out + } + + #[test] + fn test_write_binary_data_uri() { + let photo = "iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAQAAAADpb+tAAAAQklEQVQI122PQQ4AMAj\ + CKv//Mzs4M0zmRYKkamEwWQVoRJogk4PuRoOoMC/EK8nYb+l08WGvSxKlNHO5kxnp/\ + WXrAzsSERN1N6q5AAAAAElFTkSuQmCC"; + + for (input, version, expect) in [ + ( + format!("PHOTO;TYPE=JPEG;ENCODING=b:{photo}"), + VCardVersion::V4_0, + format!("PHOTO;TYPE=JPEG:data:image/jpeg;base64\\,{photo}"), + ), + ( + format!("PHOTO;TYPE=JPEG;ENCODING=b:{photo}"), + VCardVersion::V3_0, + format!("PHOTO;TYPE=JPEG;ENCODING=b:{photo}"), + ), + ( + format!("PHOTO;ENCODING=b:{photo}"), + VCardVersion::V4_0, + format!("PHOTO:data:;base64\\,{photo}"), + ), + ( + format!("SOUND;TYPE=WAVE;ENCODING=b:{photo}"), + VCardVersion::V4_0, + format!("SOUND;TYPE=WAVE:data:audio/wav;base64\\,{photo}"), + ), + ( + format!("PHOTO;TYPE=WORK;ENCODING=b:{photo}"), + VCardVersion::V4_0, + format!("PHOTO;TYPE=WORK:data:;base64\\,{photo}"), + ), + ] { + let vcard = parse(&format!( + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:T\r\n{input}\r\nEND:VCARD\r\n" + )); + let out = write(&vcard, version).replace("\r\n ", ""); + + assert!( + out.contains(expect.as_str()), + "expected {expect} in {out} for {input}" + ); + } + } + + #[test] + fn test_write_binary_data_uri_v4_to_v3() { + let vcard = parse( + "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:T\r\n\ + PHOTO:data:image/png;base64\\,iVBORw0KGgo=\r\nEND:VCARD\r\n", + ); + + assert!( + write(&vcard, VCardVersion::V3_0).contains("PHOTO;ENCODING=b;TYPE=PNG:iVBORw0KGgo="), + "{}", + write(&vcard, VCardVersion::V3_0) + ); + } + + #[test] + fn test_write_fold_width() { + let filler = "A".repeat(73); + let long_param = "B".repeat(64); + let component = "C".repeat(71); + + for input in [ + format!("N:{filler};B;C;D;E"), + format!("N:{filler};;;;"), + format!("ADR:{component};;;;;;"), + format!("N:{component},,,,,;;;;"), + format!("NOTE;X-FOO={long_param}:hello"), + format!("NOTE:{}", "D".repeat(500)), + ] { + let vcard = parse(&format!( + "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:T\r\n{input}\r\nEND:VCARD\r\n" + )); + + for version in [VCardVersion::V3_0, VCardVersion::V4_0] { + let out = write(&vcard, version); + crate::common::writer::assert_fold_width(&out, &input); + + let reparsed = parse(&out); + assert_eq!( + write(&reparsed, version), + out, + "not idempotent for {input} at version {version}" + ); + } + } + } + + #[test] + fn test_write_jscomps() { + let filler = "-".repeat(55); + let folded_separator = format!("{}\r\n {}", "-".repeat(60), "=".repeat(10)); + + for input in [ + format!("N;JSCOMPS=\"s,{filler};1;2;3;0\":a;b;c;d;e"), + format!("N;JSCOMPS=\"s,{folded_separator};1;2\":a;b;c;d;e"), + format!("N;JSCOMPS=\"s,{};12;34,5\":a;b;c;d;e", "-".repeat(54)), + "N;JSCOMPS=\"s,==========;1;2\":a;b;c;d;e".to_string(), + "N;JSCOMPS=\"s,\\\\;1;2\":a;b;c;d;e".to_string(), + ] { + let vcard = parse(&format!( + "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:T\r\n{input}\r\nEND:VCARD\r\n" + )); + let out = write(&vcard, VCardVersion::V4_0); + crate::common::writer::assert_fold_width(&out, &input); + + assert_eq!( + parse(&out).entries, + vcard.entries, + "JSCOMPS did not survive a round trip for {input}\n{out}" + ); + } + } +} diff --git a/vendor/sieve-rs/VENDORED.md b/vendor/sieve-rs/VENDORED.md index 42c3fab5..7e5c7d60 100644 --- a/vendor/sieve-rs/VENDORED.md +++ b/vendor/sieve-rs/VENDORED.md @@ -29,6 +29,19 @@ Every changed file carries the AGPL 5(a) notice in its header. against. Every caller of `Comparator::regex` passes the cache. The `sieve_regexes_are_size_limited` and `sieve_variable_patterns_compile_once` tests in `crates/common` cover both. +- `src/runtime/context.rs`, `src/lib.rs`, `src/runtime/tests/test_header.rs`, + `test_address.rs`, `test_body.rs`, `test_string.rs`, `test_exists.rs`, + `src/runtime/actions/action_editheader.rs`: the bytes that tests scan count + against the cpu limit, one instruction for every 64 KiB + (`SCAN_BYTES_PER_INSTRUCTION`). Header, address, body and string tests and + `deleteheader` charge each value they compare times the number of keys, and + every search for a header charges the part's header block; the total is + `Context::scanned_bytes`. A test stops scanning once the run is over the + limit, and the run fails with `CPULimitReached` as it does for too many + instructions (`deleteheader` then deletes nothing). The `scan_limit_tests` + in `context.rs` cover it; they run from this directory with `cargo test + --offline --target-dir ../../target/sieve-rs-tests scan_limit` (the crate's + `test_suite` and `parse_rfc` need upstream's `tests/`, which isn't kept). - `Cargo.toml`: the example target is dropped, with the top-level `examples/` and `tests/`, which the server never builds. (`src/` keeps its own `tests` modules: the crate declares them.) diff --git a/vendor/sieve-rs/src/lib.rs b/vendor/sieve-rs/src/lib.rs index 0ad11608..97b91735 100644 --- a/vendor/sieve-rs/src/lib.rs +++ b/vendor/sieve-rs/src/lib.rs @@ -138,6 +138,7 @@ pub struct Context<'x> { pub(crate) num_instructions: usize, pub(crate) num_out_messages: usize, pub(crate) regex_cache: runtime::tests::comparator::RegexCache, + pub(crate) scanned_bytes: std::cell::Cell, } #[derive(Debug, Clone, Eq, PartialEq, Hash)] diff --git a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs index a124cf6a..8640aff3 100644 --- a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs +++ b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs @@ -73,8 +73,16 @@ impl DeleteHeader { self.index, self.mime_anychild, |header, part_id, header_pos| { + if ctx.is_over_cpu_limit() { + return true; + } if !value_patterns.is_empty() { let did_match = ctx.find_header_values(header, &MimeOpts::None, |value| { + // inbuxa: charged like the header test; past the limit, + // nothing is deleted and the run stops + if ctx.charge_scan(value.len() * value_patterns.len()) { + return false; + } for (pattern_expr, pattern) in value_patterns.iter().zip(self.value_patterns.iter()) { @@ -125,6 +133,10 @@ impl DeleteHeader { }, ); + if ctx.is_over_cpu_limit() { + return; + } + if !deleted_headers.is_empty() { ctx.has_changes = true; for (part_id, header_pos) in deleted_headers.iter().rev() { diff --git a/vendor/sieve-rs/src/runtime/context.rs b/vendor/sieve-rs/src/runtime/context.rs index 64a6c4ff..56019ad0 100644 --- a/vendor/sieve-rs/src/runtime/context.rs +++ b/vendor/sieve-rs/src/runtime/context.rs @@ -23,6 +23,11 @@ use super::{ tests::{TestResult, test_envelope::parse_envelope_address}, }; +// inbuxa: tests are charged one instruction for every 64 KiB they scan (the +// bytes compared, times the keys compared against), so the cpu limit also bounds +// how much of a large message a script can read +pub(crate) const SCAN_BYTES_PER_INSTRUCTION: usize = 64 * 1024; + #[derive(Clone, Debug)] pub(crate) struct ScriptStack { pub(crate) script: Arc, @@ -73,6 +78,7 @@ impl<'x> Context<'x> { num_instructions: 0, num_out_messages: 0, regex_cache: Default::default(), + scanned_bytes: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, @@ -127,7 +133,7 @@ impl<'x> Context<'x> { 'outer: loop { while let Some(instruction) = iter.next() { self.num_instructions += 1; - if self.num_instructions > self.runtime.cpu_limit { + if self.is_over_cpu_limit() { self.finish_loop(); return Some(Err(RuntimeError::CPULimitReached)); } @@ -157,6 +163,11 @@ impl<'x> Context<'x> { continue; } Instruction::Test(test) => match test.exec(self) { + // inbuxa: a test that scanned past the limit stops the run + _ if self.is_over_cpu_limit() => { + self.finish_loop(); + return Some(Err(RuntimeError::CPULimitReached)); + } TestResult::Bool(result) => { self.test_result = result; } @@ -304,7 +315,13 @@ impl<'x> Context<'x> { } } Instruction::AddHeader(add_header) => add_header.exec(self), - Instruction::DeleteHeader(delete_header) => delete_header.exec(self), + Instruction::DeleteHeader(delete_header) => { + delete_header.exec(self); + if self.is_over_cpu_limit() { + self.finish_loop(); + return Some(Err(RuntimeError::CPULimitReached)); + } + } Instruction::Set(set) => { set.exec(self); if let Some(event) = self.queued_events.next() { @@ -443,6 +460,22 @@ impl<'x> Context<'x> { } } + // inbuxa: adds `bytes` to what this run's tests have scanned; true once the + // run is over its cpu limit, so the caller can stop scanning + #[inline] + pub(crate) fn charge_scan(&self, bytes: usize) -> bool { + self.scanned_bytes + .set(self.scanned_bytes.get().saturating_add(bytes)); + self.is_over_cpu_limit() + } + + #[inline] + pub(crate) fn is_over_cpu_limit(&self) -> bool { + self.num_instructions + .saturating_add(self.scanned_bytes.get() / SCAN_BYTES_PER_INSTRUCTION) + > self.runtime.cpu_limit + } + pub(crate) fn finish_loop(&mut self) { self.script_stack.clear(); if let Some(event) = self.final_event.take() { @@ -684,6 +717,7 @@ impl<'x> Context<'x> { num_instructions: 0, num_out_messages: 0, regex_cache: Default::default(), + scanned_bytes: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, @@ -691,3 +725,115 @@ impl<'x> Context<'x> { } } } + +// inbuxa: the bytes that tests scan count against the cpu limit +#[cfg(test)] +mod scan_limit_tests { + use mail_parser::MessageParser; + + use crate::{ + Compiler, Context, Input, Runtime, compiler::grammar::Capability, runtime::RuntimeError, + }; + + fn run(script: &str, raw_message: &[u8], cpu_limit: usize) -> Result { + let script = Compiler::new().compile(script.as_bytes()).unwrap(); + let runtime = Runtime::new() + .with_capability(Capability::While) + .with_capability(Capability::Expressions) + .with_cpu_limit(cpu_limit); + let mut ctx = Context::new(&runtime, MessageParser::new().parse(raw_message).unwrap()); + let mut input = Input::script("test", script); + while let Some(result) = ctx.run(input) { + result?; + input = Input::True; + } + Ok(ctx.num_instructions) + } + + fn assert_limited(result: Result) { + assert!( + matches!(result, Err(RuntimeError::CPULimitReached)), + "expected CPULimitReached, got {result:?}" + ); + } + + fn keys(n: usize) -> String { + (0..n) + .map(|i| format!("\"key-{i}\"")) + .collect::>() + .join(", ") + } + + fn large_body(size: usize) -> Vec { + let mut raw = b"From: a@example.org\r\nTo: b@example.org\r\nSubject: hi\r\n\r\n".to_vec(); + while raw.len() < size { + raw.extend_from_slice(b"lorem ipsum dolor sit amet consectetur adipiscing elit\r\n"); + } + raw + } + + #[test] + fn body_scans_count_against_cpu_limit() { + let raw = large_body(4 << 20); + + // one test, many keys: 4 MiB x 100 keys is 6400 instructions' worth + let script = format!( + "require \"body\";\nif body :raw :contains [{}] {{ stop; }}\n", + keys(100) + ); + assert_limited(run(&script, &raw, 5000)); + + // a loop over one key + let script = "require [\"body\", \"variables\", \"vnd.inbuxa.while\", \"vnd.inbuxa.expressions\"];\n\ + set \"i\" \"0\";\n\ + while \"i < 100\" {\n\ + if body :raw :contains \"not-there\" { stop; }\n\ + let \"i\" \"i + 1\";\n\ + }\n"; + assert_limited(run(script, &raw, 2000)); + } + + #[test] + fn header_scans_count_against_cpu_limit() { + let mut raw = b"From: a@example.org\r\nTo: b@example.org\r\n".to_vec(); + while raw.len() < 4 << 20 { + raw.extend_from_slice( + b"X-Filler: lorem ipsum dolor sit amet consectetur adipiscing elit\r\n", + ); + } + raw.extend_from_slice(b"Subject: hi\r\n\r\nbody\r\n"); + + let script = format!( + "if header :contains \"X-Filler\" [{}] {{ stop; }}\n", + keys(100) + ); + assert_limited(run(&script, &raw, 5000)); + + let script = format!( + "require \"variables\";\nif string :contains \"${{x}}\" [{}] {{ stop; }}\nif address :contains \"From\" [{}] {{ stop; }}\n", + keys(10), + keys(10) + ); + assert!(run(&script, &raw, 5000).is_ok()); + } + + #[test] + fn small_messages_cost_nothing_extra() { + let raw = large_body(16 << 10); + let script = "require [\"body\", \"variables\", \"editheader\"];\n\ + if header :contains [\"Subject\", \"To\"] [\"a\", \"b\", \"c\"] { set \"x\" \"1\"; }\n\ + if address :domain \"From\" \"example.org\" { set \"y\" \"1\"; }\n\ + if exists \"X-Spam\" { stop; }\n\ + if body :text :contains [\"one\", \"two\"] { stop; }\n\ + if string :is \"${x}\" \"2\" { stop; }\n\ + deleteheader :contains \"Subject\" \"zzz\";\n"; + + // the instructions alone fill the limit; the scans add nothing + let num_instructions = run(script, &raw, 5000).unwrap(); + assert_eq!( + run(script, &raw, num_instructions).ok(), + Some(num_instructions) + ); + assert_limited(run(script, &raw, num_instructions - 1)); + } +} diff --git a/vendor/sieve-rs/src/runtime/tests/test_address.rs b/vendor/sieve-rs/src/runtime/tests/test_address.rs index 13cbe48c..6396ad67 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_address.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_address.rs @@ -41,6 +41,9 @@ impl TestAddress { self.mime_anychild, |header, _, _| { ctx.find_addresses(header, &self.address_part, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for key in &key_list { if is_is { if self.comparator.is(&value, key) { @@ -62,6 +65,9 @@ impl TestAddress { self.mime_anychild, |header, _, _| { ctx.find_addresses(header, &self.address_part, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for key in &key_list { if self.comparator.relational(rel_match, &value, key) { return true; @@ -80,6 +86,9 @@ impl TestAddress { self.mime_anychild, |header, _, _| { ctx.find_addresses(header, &self.address_part, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for (pattern_expr, pattern) in key_list.iter().zip(self.key_list.iter()) { if is_matches { @@ -145,6 +154,10 @@ impl TestAddress { self.mime_anychild, |header, _, _| { ctx.find_addresses(header, &self.address_part, |value| { + // inbuxa: each value is compared with those kept so far + if ctx.charge_scan(value.len().saturating_mul(values.len() + 1)) { + return true; + } if !value.is_empty() && !values.iter().any(|v| v.eq(value)) { values.push(value.to_string()); } diff --git a/vendor/sieve-rs/src/runtime/tests/test_body.rs b/vendor/sieve-rs/src/runtime/tests/test_body.rs index 4ab03c6b..1a8b91b2 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_body.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_body.rs @@ -32,6 +32,10 @@ impl TestBody { ctx.message.header_raw("Subject").unwrap_or_default() }; + // inbuxa: charged like any other scan + if ctx.charge_scan(subject.len() * key_list.len()) { + return TestResult::Bool(false); + } for (key, pattern) in key_list.iter().zip(self.key_list.iter()) { let result = match &self.match_type { MatchType::Is => self.comparator.is(&subject, key), @@ -192,6 +196,10 @@ impl TestBody { return false; } }; + // inbuxa: extracting the text scans it once, and each key again + if ctx.charge_scan(text.len() * key_list.len().max(1)) { + return true; + } let mut result = false; for (key, pattern) in key_list.iter().zip(self.key_list.iter()) { diff --git a/vendor/sieve-rs/src/runtime/tests/test_exists.rs b/vendor/sieve-rs/src/runtime/tests/test_exists.rs index 2213e227..89294d54 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_exists.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_exists.rs @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{Context, compiler::grammar::tests::test_exists::TestExists}; -use super::{TestResult, mime::SubpartIterator}; +use super::{TestResult, mime::SubpartIterator, test_header::header_bytes}; impl TestExists { pub(crate) fn exec(&self, ctx: &mut Context) -> TestResult { @@ -18,6 +20,10 @@ impl TestExists { while let Some((_, message_part)) = part_iter.next() { for (pos, header_name) in header_names.iter().enumerate() { + // inbuxa: looking for a header scans the part's headers + if !header_exists[pos] && ctx.charge_scan(header_bytes(message_part)) { + return TestResult::Bool(false); + } if !header_exists[pos] && message_part.headers.iter().any(|h| &h.name == header_name) { diff --git a/vendor/sieve-rs/src/runtime/tests/test_header.rs b/vendor/sieve-rs/src/runtime/tests/test_header.rs index 58ddd483..9febd081 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_header.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_header.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use mail_parser::{Header, HeaderName, HeaderValue, parsers::MessageStream}; +use mail_parser::{Header, HeaderName, HeaderValue, MessagePart, parsers::MessageStream}; use crate::{ Context, Event, @@ -40,6 +40,9 @@ impl TestHeader { self.mime_anychild, |header, _, _| { ctx.find_header_values(header, &mime_opts, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for key in &key_list { if is_is { if self.comparator.is(&value, key) { @@ -61,6 +64,9 @@ impl TestHeader { self.mime_anychild, |header, _, _| { ctx.find_header_values(header, &mime_opts, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for key in &key_list { if self.comparator.relational(rel_match, &value, key) { return true; @@ -79,6 +85,9 @@ impl TestHeader { self.mime_anychild, |header, _, _| { ctx.find_header_values(header, &mime_opts, |value| { + if ctx.charge_scan(value.len() * key_list.len()) { + return true; + } for (pattern_expr, pattern) in key_list.iter().zip(self.key_list.iter()) { if is_matches { @@ -163,6 +172,10 @@ impl TestHeader { self.mime_anychild, |header, _, _| { ctx.find_header_values(header, &mime_opts, |value| { + // inbuxa: each value is compared with those kept so far + if ctx.charge_scan(value.len().saturating_mul(values.len() + 1)) { + return true; + } if !value.is_empty() && !values.iter().any(|v| v.eq(value)) { values.push(value.to_string()); } @@ -228,6 +241,10 @@ impl Context<'_> { while let Some((part_id, message_part)) = part_iter.next() { 'outer: for header_name in header_names { + // inbuxa: looking for a header scans the part's headers + if self.charge_scan(header_bytes(message_part)) { + return true; + } match index { None => { for (pos, header) in message_part @@ -365,3 +382,9 @@ impl Context<'_> { } } } + +// inbuxa: the size of a part's header block, as it came in +pub(crate) fn header_bytes(part: &MessagePart) -> usize { + part.raw_body_offset() + .saturating_sub(part.raw_header_offset()) as usize +} diff --git a/vendor/sieve-rs/src/runtime/tests/test_string.rs b/vendor/sieve-rs/src/runtime/tests/test_string.rs index e918ed79..7bfa3af2 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_string.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_string.rs @@ -65,6 +65,9 @@ impl TestString { let key = ctx.eval_value(pattern); for source in &sources { if !empty_is_null || !source.is_empty() { + if ctx.charge_scan(source.len()) { + return TestResult::Bool(false); + } result = match &self.match_type { MatchType::Is => self.comparator.is(source, &key), MatchType::Contains => self.comparator.contains(