1use std::borrow::Cow;
7
8use ego_tree::iter::Edge;
9use scraper::{ElementRef, Html, Node, Selector};
10
11use crate::error::Error;
12use crate::generated::routes;
13use crate::generated::types::WorkflowStage;
14use crate::http::Method;
15use crate::observability::OperationInfo;
16use crate::services::write_info;
17
18pub use crate::generated::services::workflows::*;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
22#[non_exhaustive]
23pub struct WorkflowStageTopic {
24 pub staging_id: i64,
27 pub topic_id: i64,
29 pub subject: String,
31 pub entry_count: u64,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
38#[non_exhaustive]
39pub struct WorkflowStageView {
40 pub id: i64,
42 pub name: String,
44 pub topics: Vec<WorkflowStageTopic>,
46}
47
48impl WorkflowStageView {
49 pub fn parse(html: &str, stage_id: i64) -> Result<WorkflowStageView, Error> {
57 let document = Html::parse_document(html);
58 let stage = document
59 .select(&selector(&format!(
60 "[id=\"container_workflow_stage_{stage_id}\"]"
61 )))
62 .next()
63 .ok_or_else(|| Error::not_found("workflow stage", stage_id))?;
64 let name = first_at_or_under(stage, |element| element.value().name() == "h2")
65 .map(visible_text)
66 .unwrap_or_default();
67 let topics = stage
68 .select(&selector("[id^=\"topic_\"]"))
69 .filter(|card| !inside_another_card(*card, stage))
70 .filter_map(topic)
71 .collect();
72 Ok(WorkflowStageView {
73 id: stage_id,
74 name,
75 topics,
76 })
77 }
78}
79
80fn topic(card: ElementRef<'_>) -> Option<WorkflowStageTopic> {
81 let topic_id = positive(card.attr("id")?.strip_prefix("topic_")?)?;
82 let staging_id = positive(card.attr("data-identifier")?)?;
83 let subject = first_at_or_under(card, |element| element.value().name() == "h3")
84 .map(visible_text)
85 .unwrap_or_default();
86 let entry_count = match first_at_or_under(card, is_detail_line) {
87 None => 0,
88 Some(detail) => visible_text(detail)
89 .split_whitespace()
90 .next()?
91 .parse::<i64>()
92 .ok()
93 .and_then(|count| u64::try_from(count).ok())?,
94 };
95 Some(WorkflowStageTopic {
96 staging_id,
97 topic_id,
98 subject,
99 entry_count,
100 })
101}
102
103fn is_detail_line(element: ElementRef<'_>) -> bool {
106 element.value().name() == "p"
107 && element
108 .attr("class")
109 .is_some_and(|class| class.contains("card__detail"))
110}
111
112fn positive(value: &str) -> Option<i64> {
113 value.parse::<i64>().ok().filter(|id| *id > 0)
114}
115
116fn first_at_or_under<'a>(
119 root: ElementRef<'a>,
120 matches: impl Fn(ElementRef<'a>) -> bool,
121) -> Option<ElementRef<'a>> {
122 root.descendants()
123 .filter_map(ElementRef::wrap)
124 .find(|element| matches(*element))
125}
126
127fn inside_another_card(card: ElementRef<'_>, stage: ElementRef<'_>) -> bool {
131 card.ancestors()
132 .take_while(|ancestor| ancestor.id() != stage.id())
133 .filter_map(ElementRef::wrap)
134 .any(|ancestor| {
135 ancestor
136 .attr("id")
137 .is_some_and(|id| id.starts_with("topic_"))
138 })
139}
140
141fn visible_text(element: ElementRef<'_>) -> String {
145 let mut text = String::new();
146 let mut hidden_depth = 0usize;
147 for edge in element.traverse() {
148 match edge {
149 Edge::Open(node) => {
150 if hidden_depth > 0 {
151 hidden_depth += 1;
152 } else {
153 match node.value() {
154 Node::Element(element) if is_visually_hidden(element) => {
155 hidden_depth = 1;
156 }
157 Node::Text(content) => text.push_str(content),
158 _ => {}
159 }
160 }
161 }
162 Edge::Close(_) => hidden_depth = hidden_depth.saturating_sub(1),
163 }
164 }
165 text.split_whitespace().collect::<Vec<_>>().join(" ")
166}
167
168fn is_visually_hidden(element: &scraper::node::Element) -> bool {
171 element.attr("class").is_some_and(|classes| {
172 classes.split_whitespace().any(|class| {
173 matches!(
174 class,
175 "sr-only" | "screen-reader-only" | "u-for-screen-reader" | "visually-hidden"
176 )
177 })
178 })
179}
180
181fn selector(css: &str) -> Selector {
183 Selector::parse(css).unwrap_or_else(|error| unreachable!("selector {css:?}: {error}"))
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
188#[non_exhaustive]
189pub struct WorkflowSummary {
190 pub id: i64,
192 pub name: String,
194 pub account_name: String,
196}
197
198impl Workflows<'_> {
199 pub async fn list(&self, account_id: i64) -> Result<Vec<WorkflowSummary>, Error> {
205 let mut operation = self.client().request(
206 Method::GET,
207 format!("/autocompletable/accounts/{account_id}/workflows"),
208 );
209 operation
210 .info(OperationInfo {
211 service: Cow::Borrowed("Workflows"),
212 operation: Cow::Borrowed("ListWorkflows"),
213 resource_type: Cow::Borrowed("workflow"),
214 is_mutation: false,
215 resource_id: Some(account_id),
216 })
217 .without_json_suffix();
218
219 let rows: Vec<Vec<String>> = self.client().send(operation).await?;
220 Ok(rows.iter().filter_map(|row| summary(row)).collect())
221 }
222
223 pub async fn stages(&self, workflow_id: i64) -> Result<Vec<WorkflowStage>, Error> {
225 Ok(self.get(workflow_id).await?.stages.unwrap_or_default())
226 }
227
228 pub async fn stage(&self, workflow_id: i64, stage_id: i64) -> Result<WorkflowStageView, Error> {
232 let page = self.get_stage(workflow_id, stage_id).await?;
233 WorkflowStageView::parse(&page, stage_id)
234 }
235
236 pub async fn create(&self, name: &str, account_id: Option<i64>) -> Result<(), Error> {
238 let account = account_id
239 .filter(|account_id| *account_id != 0)
240 .map(|account_id| account_id.to_string());
241 let mut fields = vec![("workflow[name]", name)];
242 if let Some(account) = &account {
243 fields.push(("account_id", account.as_str()));
244 }
245
246 let mut operation = self.client().form(Method::POST, "/workflows")?;
247 operation.info(write_info("Workflows", "CreateWorkflow", "workflow", None));
248 operation.form(&fields);
249 self.client().send_unit(operation).await
250 }
251
252 pub async fn update(&self, workflow_id: i64, name: &str) -> Result<(), Error> {
254 let mut operation = self
255 .client()
256 .form(Method::PATCH, &format!("/workflows/{workflow_id}"))?;
257 operation.info(write_info(
258 "Workflows",
259 "UpdateWorkflow",
260 "workflow",
261 Some(workflow_id),
262 ));
263 operation.form(&[("workflow[name]", name)]);
264 self.client().send_unit(operation).await
265 }
266
267 pub async fn delete(&self, workflow_id: i64) -> Result<(), Error> {
269 let mut operation = self
270 .client()
271 .form(Method::DELETE, &format!("/workflows/{workflow_id}"))?;
272 operation.info(write_info(
273 "Workflows",
274 "DeleteWorkflow",
275 "workflow",
276 Some(workflow_id),
277 ));
278 self.client().send_unit(operation).await
279 }
280
281 pub async fn create_stage(&self, workflow_id: i64) -> Result<(), Error> {
284 let mut operation = self
285 .client()
286 .form(Method::POST, &format!("/workflows/{workflow_id}/stages"))?;
287 operation.info(write_info(
288 "Workflows",
289 "CreateWorkflowStage",
290 "workflow_stage",
291 Some(workflow_id),
292 ));
293 operation.form(&[]);
294 self.client().send_unit(operation).await
295 }
296
297 pub async fn update_stage(
299 &self,
300 workflow_id: i64,
301 stage_id: i64,
302 name: &str,
303 ) -> Result<(), Error> {
304 let mut operation = self.client().form(
305 Method::PATCH,
306 &format!("/workflows/{workflow_id}/stages/{stage_id}"),
307 )?;
308 operation.info(write_info(
309 "Workflows",
310 "UpdateWorkflowStage",
311 "workflow_stage",
312 Some(stage_id),
313 ));
314 operation.form(&[("workflow_stage[name]", name)]);
315 self.client().send_unit(operation).await
316 }
317
318 pub async fn delete_stage(&self, workflow_id: i64, stage_id: i64) -> Result<(), Error> {
320 let mut operation = self.client().form(
321 Method::DELETE,
322 &format!("/workflows/{workflow_id}/stages/{stage_id}"),
323 )?;
324 operation.info(write_info(
325 "Workflows",
326 "DeleteWorkflowStage",
327 "workflow_stage",
328 Some(stage_id),
329 ));
330 self.client().send_unit(operation).await
331 }
332
333 pub async fn stage_topic(
343 &self,
344 topic_id: i64,
345 workflow_id: i64,
346 stage_id: i64,
347 ) -> Result<(), Error> {
348 let mut operation = self
349 .client()
350 .operation(&routes::CREATE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
351 operation
352 .info(write_info(
353 "Workflows",
354 "CreateWorkflowStaging",
355 "workflow_staging",
356 Some(topic_id),
357 ))
358 .form_representation();
359 self.client()
361 .within_limit(Box::pin(async {
362 self.client().send_unit(operation).await?;
363 self.move_to_stage(topic_id, workflow_id, stage_id, None)
364 .await
365 }))
366 .await
367 }
368
369 pub async fn move_topic_to_stage(
373 &self,
374 topic_id: i64,
375 workflow_id: i64,
376 stage_id: i64,
377 ) -> Result<(), Error> {
378 let info = write_info(
379 "Workflows",
380 "MoveWorkflowStaging",
381 "workflow_staging",
382 Some(topic_id),
383 );
384 self.move_to_stage(topic_id, workflow_id, stage_id, Some(info))
385 .await
386 }
387
388 pub async fn unstage_topic(&self, topic_id: i64, workflow_id: i64) -> Result<(), Error> {
390 let mut operation = self.client().form(
391 Method::DELETE,
392 &format!("/topics/{topic_id}/workflows/{workflow_id}/stagings"),
393 )?;
394 operation.info(write_info(
395 "Workflows",
396 "DeleteWorkflowStaging",
397 "workflow_staging",
398 Some(topic_id),
399 ));
400 self.client().send_unit(operation).await
401 }
402
403 async fn move_to_stage(
407 &self,
408 topic_id: i64,
409 workflow_id: i64,
410 stage_id: i64,
411 info: Option<OperationInfo>,
412 ) -> Result<(), Error> {
413 let stage = stage_id.to_string();
414 let mut operation = self
415 .client()
416 .operation(&routes::MOVE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
417 operation
418 .form_representation()
419 .form(&[("workflow_staging[workflow_stage_id]", stage.as_str())]);
420 match info {
421 Some(info) => operation.info(info),
422 None => operation.quiet(),
423 };
424 self.client().send_unit(operation).await
425 }
426}
427
428fn summary(row: &[String]) -> Option<WorkflowSummary> {
431 match row {
432 [id, name, rest @ ..] => Some(WorkflowSummary {
433 id: id.parse().ok()?,
434 name: name.clone(),
435 account_name: rest.first().cloned().unwrap_or_default(),
436 }),
437 _ => None,
438 }
439}