clickup_cli/commands/
pagination.rs1use crate::client::ClickUpClient;
27use crate::error::CliError;
28use crate::Cli;
29use serde_json::Value;
30
31const MAX_PAGES: usize = 100;
34
35fn extract_array(resp: &Value, keys: &[&str]) -> Option<Vec<Value>> {
39 for key in keys {
40 if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {
41 return Some(arr.clone());
42 }
43 }
44 if let Some(arr) = resp.as_array() {
45 return Some(arr.clone());
46 }
47 None
48}
49
50pub async fn walk_page<F>(
54 cli: &Cli,
55 client: &ClickUpClient,
56 items_key: &str,
57 build_path: F,
58) -> Result<Vec<Value>, CliError>
59where
60 F: Fn(u32) -> String,
61{
62 let start_page = cli.page.unwrap_or(0);
63 let mut collected: Vec<Value> = Vec::new();
64 let mut current_page = start_page;
65 let mut pages_fetched = 0usize;
66
67 loop {
68 let resp = client.get(&build_path(current_page)).await?;
69 let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
70 let last_page = resp
71 .get("last_page")
72 .and_then(|v| v.as_bool())
73 .unwrap_or(items.is_empty());
74 collected.extend(items);
75 pages_fetched += 1;
76
77 if !cli.all {
78 break;
79 }
80 if last_page || pages_fetched >= MAX_PAGES {
81 break;
82 }
83 if let Some(limit) = cli.limit {
84 if collected.len() >= limit {
85 break;
86 }
87 }
88 current_page += 1;
89 }
90
91 if let Some(limit) = cli.limit {
92 collected.truncate(limit);
93 }
94 Ok(collected)
95}
96
97pub async fn walk_cursor<F>(
102 cli: &Cli,
103 client: &ClickUpClient,
104 items_keys: &[&str],
105 build_path: F,
106) -> Result<Vec<Value>, CliError>
107where
108 F: Fn(Option<&str>) -> String,
109{
110 let mut cursor = cli.cursor.clone();
111 let mut collected: Vec<Value> = Vec::new();
112 let mut pages_fetched = 0usize;
113
114 loop {
115 let resp = client.get(&build_path(cursor.as_deref())).await?;
116 let items = extract_array(&resp, items_keys).unwrap_or_default();
117 let next_cursor: Option<String> = resp
118 .get("next_cursor")
119 .and_then(|v| v.as_str())
120 .filter(|s| !s.is_empty())
121 .map(String::from);
122 collected.extend(items);
123 pages_fetched += 1;
124
125 if !cli.all {
126 break;
127 }
128 if next_cursor.is_none() || pages_fetched >= MAX_PAGES {
129 break;
130 }
131 if let Some(limit) = cli.limit {
132 if collected.len() >= limit {
133 break;
134 }
135 }
136 cursor = next_cursor;
137 }
138
139 if let Some(limit) = cli.limit {
140 collected.truncate(limit);
141 }
142 Ok(collected)
143}
144
145pub async fn walk_start_id<F>(
151 cli: &Cli,
152 client: &ClickUpClient,
153 items_key: &str,
154 build_path: F,
155) -> Result<Vec<Value>, CliError>
156where
157 F: Fn(Option<i64>, Option<&str>) -> String,
158{
159 const PAGE_HINT: usize = 25;
160 let mut current_start = cli.start;
161 let mut current_start_id = cli.start_id.clone();
162 let mut collected: Vec<Value> = Vec::new();
163 let mut pages_fetched = 0usize;
164
165 loop {
166 let resp = client
167 .get(&build_path(current_start, current_start_id.as_deref()))
168 .await?;
169 let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
170 let count = items.len();
171
172 let next_boundary = items.last().and_then(|last| {
174 let date = last
175 .get("date")
176 .and_then(|v| v.as_str())
177 .and_then(|s| s.parse::<i64>().ok())
178 .or_else(|| last.get("date").and_then(|v| v.as_i64()));
179 let id = last.get("id").and_then(|v| v.as_str()).map(String::from);
180 match (date, id) {
181 (Some(d), Some(i)) => Some((d, i)),
182 _ => None,
183 }
184 });
185
186 collected.extend(items);
187 pages_fetched += 1;
188
189 if !cli.all {
190 break;
191 }
192 if count < PAGE_HINT || pages_fetched >= MAX_PAGES {
193 break;
194 }
195 if let Some(limit) = cli.limit {
196 if collected.len() >= limit {
197 break;
198 }
199 }
200 match next_boundary {
201 Some((d, i)) => {
202 current_start = Some(d);
203 current_start_id = Some(i);
204 }
205 None => break,
206 }
207 }
208
209 if let Some(limit) = cli.limit {
210 collected.truncate(limit);
211 }
212 Ok(collected)
213}
214
215#[allow(clippy::too_many_arguments)]
224pub async fn walk_body<BB, NT>(
225 cli: &Cli,
226 client: &ClickUpClient,
227 path: &str,
228 items_keys: &[&str],
229 base_body: BB,
230 extra_pagination: serde_json::Map<String, Value>,
231 start_timestamp: Option<i64>,
232 next_timestamp: NT,
233) -> Result<Vec<Value>, CliError>
234where
235 BB: Fn() -> Value,
236 NT: Fn(&Value) -> Option<i64>,
237{
238 let mut current_timestamp = start_timestamp;
239 let mut collected: Vec<Value> = Vec::new();
240 let mut pages_fetched = 0usize;
241
242 loop {
243 let mut body = base_body();
244 let mut pagination = extra_pagination.clone();
245 if let Some(t) = current_timestamp {
246 pagination.insert("pageTimestamp".into(), serde_json::json!(t));
247 }
248 if !pagination.is_empty() {
249 body["pagination"] = Value::Object(pagination);
250 }
251
252 let resp = client.post(path, &body).await?;
253 let items = extract_array(&resp, items_keys).unwrap_or_default();
254 let count = items.len();
255 let next = items.last().and_then(&next_timestamp);
256 collected.extend(items);
257 pages_fetched += 1;
258
259 if !cli.all {
260 break;
261 }
262 if count == 0 || next.is_none() || pages_fetched >= MAX_PAGES {
263 break;
264 }
265 if let Some(limit) = cli.limit {
266 if collected.len() >= limit {
267 break;
268 }
269 }
270 current_timestamp = next;
271 }
272
273 if let Some(limit) = cli.limit {
274 collected.truncate(limit);
275 }
276 Ok(collected)
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use serde_json::json;
283
284 #[test]
285 fn extract_array_prefers_first_key() {
286 let resp = json!({"data": [1, 2], "tasks": [3, 4]});
287 let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
288 assert_eq!(arr.len(), 2);
289 assert_eq!(arr[0], json!(1));
290 }
291
292 #[test]
293 fn extract_array_falls_back_to_second_key() {
294 let resp = json!({"tasks": [3, 4]});
295 let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
296 assert_eq!(arr[0], json!(3));
297 }
298
299 #[test]
300 fn extract_array_falls_back_to_bare_array() {
301 let resp = json!([1, 2, 3]);
302 let arr = extract_array(&resp, &["data"]).unwrap();
303 assert_eq!(arr.len(), 3);
304 }
305
306 #[test]
307 fn extract_array_returns_none_when_no_match() {
308 let resp = json!({"foo": "bar"});
309 assert!(extract_array(&resp, &["data", "tasks"]).is_none());
310 }
311}