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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
use crate::client::AnyListClient;
use crate::error::{AnyListError, Result};
use prost::Message;
use regex::Regex;
use serde_derive::{Deserialize, Serialize};
/// iCalendar export info for meal planning calendar
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ICalendarInfo {
pub(crate) enabled: bool,
pub(crate) url: Option<String>,
pub(crate) token: Option<String>,
}
impl ICalendarInfo {
/// Whether iCalendar export is enabled
pub fn enabled(&self) -> bool {
self.enabled
}
/// The iCalendar URL for subscribing to the meal planning calendar
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
/// The iCalendar token (used to construct the URL)
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
}
/// Request message for enabling iCalendar
#[derive(Clone, PartialEq, Message)]
struct ICalendarRequest {
#[prost(bool, optional, tag = "1")]
enabled: Option<bool>,
}
impl AnyListClient {
/// Enable iCalendar export for meal planning calendar
///
/// This enables the public iCalendar URL that can be subscribed to
/// by external calendar applications like Home Assistant, Google Calendar, etc.
///
/// # Example
///
/// ```no_run
/// # use anylist_rs::AnyListClient;
/// # #[tokio::main]
/// # async fn main() {
/// let client = AnyListClient::login("user@example.com", "password")
/// .await
/// .expect("Failed to authenticate");
///
/// let info = client.enable_icalendar().await.expect("Failed to enable");
/// if let Some(url) = info.url() {
/// println!("iCalendar URL: {}", url);
/// }
/// # }
/// ```
pub async fn enable_icalendar(&self) -> Result<ICalendarInfo> {
let request = ICalendarRequest {
enabled: Some(true),
};
let mut body = Vec::new();
request.encode(&mut body).map_err(|e| {
AnyListError::ProtobufError(format!("Failed to encode iCalendar request: {}", e))
})?;
let response = self
.post_multipart(
"/data/meal-planning-calendar/set-icalendar-enabled",
"icalendar_request",
body,
)
.await?;
// Extract token from response - it's a 32-char hex string
let token = extract_icalendar_token(&response);
Ok(ICalendarInfo {
enabled: true,
url: token.as_ref().map(|t| format!("https://icalendar.anylist.com/{}.ics", t)),
token,
})
}
/// Disable iCalendar export for meal planning calendar
pub async fn disable_icalendar(&self) -> Result<()> {
let request = ICalendarRequest {
enabled: Some(false),
};
let mut body = Vec::new();
request.encode(&mut body).map_err(|e| {
AnyListError::ProtobufError(format!("Failed to encode iCalendar request: {}", e))
})?;
self.post_multipart(
"/data/meal-planning-calendar/set-icalendar-enabled",
"icalendar_request",
body,
)
.await?;
Ok(())
}
/// Get the iCalendar URL if already enabled
///
/// This fetches account info and extracts the iCalendar token if available.
pub async fn get_icalendar_url(&self) -> Result<Option<String>> {
let response = self.post("data/account/info", vec![]).await?;
let token = extract_icalendar_token(&response);
Ok(token.map(|t| format!("https://icalendar.anylist.com/{}.ics", t)))
}
}
/// Extract iCalendar token from response bytes
///
/// The token is a 32-character hex string (UUID without dashes)
fn extract_icalendar_token(data: &[u8]) -> Option<String> {
// Convert to string, looking for 32-char hex pattern
let text = String::from_utf8_lossy(data);
// Pattern: 32 hex characters that look like a UUID without dashes
let re = Regex::new(r"[a-f0-9]{32}").ok()?;
// Find matches and return the one that looks like an iCalendar token
// (typically the last one or one that's not a user ID)
for cap in re.find_iter(&text) {
let token = cap.as_str();
// Skip if it looks like a known field (user IDs typically start with specific patterns)
// The iCalendar token is usually different
if !token.starts_with("9540") {
// Skip user ID patterns
return Some(token.to_string());
}
}
// Fallback: return the last match if any
re.find_iter(&text).last().map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_token() {
let data = b"some data b2349cc3e7bc49ef85226d199916d1b6 more data";
let token = extract_icalendar_token(data);
assert_eq!(token, Some("b2349cc3e7bc49ef85226d199916d1b6".to_string()));
}
#[test]
fn test_extract_token_skips_user_id() {
// Simulated response with user ID and calendar token
let data = b"9540e2d686024900b0c28f4498db8165 other b2349cc3e7bc49ef85226d199916d1b6";
let token = extract_icalendar_token(data);
assert_eq!(token, Some("b2349cc3e7bc49ef85226d199916d1b6".to_string()));
}
}