use std::borrow::Cow;
use ego_tree::iter::Edge;
use scraper::{ElementRef, Html, Node, Selector};
use crate::error::Error;
use crate::generated::routes;
use crate::generated::types::WorkflowStage;
use crate::http::Method;
use crate::observability::OperationInfo;
use crate::services::write_info;
pub use crate::generated::services::workflows::*;
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct WorkflowStageTopic {
pub staging_id: i64,
pub topic_id: i64,
pub subject: String,
pub entry_count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct WorkflowStageView {
pub id: i64,
pub name: String,
pub topics: Vec<WorkflowStageTopic>,
}
impl WorkflowStageView {
pub fn parse(html: &str, stage_id: i64) -> Result<WorkflowStageView, Error> {
let document = Html::parse_document(html);
let stage = document
.select(&selector(&format!(
"[id=\"container_workflow_stage_{stage_id}\"]"
)))
.next()
.ok_or_else(|| Error::not_found("workflow stage", stage_id))?;
let name = first_at_or_under(stage, |element| element.value().name() == "h2")
.map(visible_text)
.unwrap_or_default();
let topics = stage
.select(&selector("[id^=\"topic_\"]"))
.filter(|card| !inside_another_card(*card, stage))
.filter_map(topic)
.collect();
Ok(WorkflowStageView {
id: stage_id,
name,
topics,
})
}
}
fn topic(card: ElementRef<'_>) -> Option<WorkflowStageTopic> {
let topic_id = positive(card.attr("id")?.strip_prefix("topic_")?)?;
let staging_id = positive(card.attr("data-identifier")?)?;
let subject = first_at_or_under(card, |element| element.value().name() == "h3")
.map(visible_text)
.unwrap_or_default();
let entry_count = match first_at_or_under(card, is_detail_line) {
None => 0,
Some(detail) => visible_text(detail)
.split_whitespace()
.next()?
.parse::<i64>()
.ok()
.and_then(|count| u64::try_from(count).ok())?,
};
Some(WorkflowStageTopic {
staging_id,
topic_id,
subject,
entry_count,
})
}
fn is_detail_line(element: ElementRef<'_>) -> bool {
element.value().name() == "p"
&& element
.attr("class")
.is_some_and(|class| class.contains("card__detail"))
}
fn positive(value: &str) -> Option<i64> {
value.parse::<i64>().ok().filter(|id| *id > 0)
}
fn first_at_or_under<'a>(
root: ElementRef<'a>,
matches: impl Fn(ElementRef<'a>) -> bool,
) -> Option<ElementRef<'a>> {
root.descendants()
.filter_map(ElementRef::wrap)
.find(|element| matches(*element))
}
fn inside_another_card(card: ElementRef<'_>, stage: ElementRef<'_>) -> bool {
card.ancestors()
.take_while(|ancestor| ancestor.id() != stage.id())
.filter_map(ElementRef::wrap)
.any(|ancestor| {
ancestor
.attr("id")
.is_some_and(|id| id.starts_with("topic_"))
})
}
fn visible_text(element: ElementRef<'_>) -> String {
let mut text = String::new();
let mut hidden_depth = 0usize;
for edge in element.traverse() {
match edge {
Edge::Open(node) => {
if hidden_depth > 0 {
hidden_depth += 1;
} else {
match node.value() {
Node::Element(element) if is_visually_hidden(element) => {
hidden_depth = 1;
}
Node::Text(content) => text.push_str(content),
_ => {}
}
}
}
Edge::Close(_) => hidden_depth = hidden_depth.saturating_sub(1),
}
}
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn is_visually_hidden(element: &scraper::node::Element) -> bool {
element.attr("class").is_some_and(|classes| {
classes.split_whitespace().any(|class| {
matches!(
class,
"sr-only" | "screen-reader-only" | "u-for-screen-reader" | "visually-hidden"
)
})
})
}
fn selector(css: &str) -> Selector {
Selector::parse(css).unwrap_or_else(|error| unreachable!("selector {css:?}: {error}"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorkflowSummary {
pub id: i64,
pub name: String,
pub account_name: String,
}
impl Workflows<'_> {
pub async fn list(&self, account_id: i64) -> Result<Vec<WorkflowSummary>, Error> {
let mut operation = self.client().request(
Method::GET,
format!("/autocompletable/accounts/{account_id}/workflows"),
);
operation
.info(OperationInfo {
service: Cow::Borrowed("Workflows"),
operation: Cow::Borrowed("ListWorkflows"),
resource_type: Cow::Borrowed("workflow"),
is_mutation: false,
resource_id: Some(account_id),
})
.without_json_suffix();
let rows: Vec<Vec<String>> = self.client().send(operation).await?;
Ok(rows.iter().filter_map(|row| summary(row)).collect())
}
pub async fn stages(&self, workflow_id: i64) -> Result<Vec<WorkflowStage>, Error> {
Ok(self.get(workflow_id).await?.stages.unwrap_or_default())
}
pub async fn stage(&self, workflow_id: i64, stage_id: i64) -> Result<WorkflowStageView, Error> {
let page = self.get_stage(workflow_id, stage_id).await?;
WorkflowStageView::parse(&page, stage_id)
}
pub async fn create(&self, name: &str, account_id: Option<i64>) -> Result<(), Error> {
let account = account_id
.filter(|account_id| *account_id != 0)
.map(|account_id| account_id.to_string());
let mut fields = vec![("workflow[name]", name)];
if let Some(account) = &account {
fields.push(("account_id", account.as_str()));
}
let mut operation = self.client().form(Method::POST, "/workflows")?;
operation.info(write_info("Workflows", "CreateWorkflow", "workflow", None));
operation.form(&fields);
self.client().send_unit(operation).await
}
pub async fn update(&self, workflow_id: i64, name: &str) -> Result<(), Error> {
let mut operation = self
.client()
.form(Method::PATCH, &format!("/workflows/{workflow_id}"))?;
operation.info(write_info(
"Workflows",
"UpdateWorkflow",
"workflow",
Some(workflow_id),
));
operation.form(&[("workflow[name]", name)]);
self.client().send_unit(operation).await
}
pub async fn delete(&self, workflow_id: i64) -> Result<(), Error> {
let mut operation = self
.client()
.form(Method::DELETE, &format!("/workflows/{workflow_id}"))?;
operation.info(write_info(
"Workflows",
"DeleteWorkflow",
"workflow",
Some(workflow_id),
));
self.client().send_unit(operation).await
}
pub async fn create_stage(&self, workflow_id: i64) -> Result<(), Error> {
let mut operation = self
.client()
.form(Method::POST, &format!("/workflows/{workflow_id}/stages"))?;
operation.info(write_info(
"Workflows",
"CreateWorkflowStage",
"workflow_stage",
Some(workflow_id),
));
operation.form(&[]);
self.client().send_unit(operation).await
}
pub async fn update_stage(
&self,
workflow_id: i64,
stage_id: i64,
name: &str,
) -> Result<(), Error> {
let mut operation = self.client().form(
Method::PATCH,
&format!("/workflows/{workflow_id}/stages/{stage_id}"),
)?;
operation.info(write_info(
"Workflows",
"UpdateWorkflowStage",
"workflow_stage",
Some(stage_id),
));
operation.form(&[("workflow_stage[name]", name)]);
self.client().send_unit(operation).await
}
pub async fn delete_stage(&self, workflow_id: i64, stage_id: i64) -> Result<(), Error> {
let mut operation = self.client().form(
Method::DELETE,
&format!("/workflows/{workflow_id}/stages/{stage_id}"),
)?;
operation.info(write_info(
"Workflows",
"DeleteWorkflowStage",
"workflow_stage",
Some(stage_id),
));
self.client().send_unit(operation).await
}
pub async fn stage_topic(
&self,
topic_id: i64,
workflow_id: i64,
stage_id: i64,
) -> Result<(), Error> {
let mut operation = self
.client()
.operation(&routes::CREATE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
operation
.info(write_info(
"Workflows",
"CreateWorkflowStaging",
"workflow_staging",
Some(topic_id),
))
.form_representation();
self.client()
.within_limit(Box::pin(async {
self.client().send_unit(operation).await?;
self.move_to_stage(topic_id, workflow_id, stage_id, None)
.await
}))
.await
}
pub async fn move_topic_to_stage(
&self,
topic_id: i64,
workflow_id: i64,
stage_id: i64,
) -> Result<(), Error> {
let info = write_info(
"Workflows",
"MoveWorkflowStaging",
"workflow_staging",
Some(topic_id),
);
self.move_to_stage(topic_id, workflow_id, stage_id, Some(info))
.await
}
pub async fn unstage_topic(&self, topic_id: i64, workflow_id: i64) -> Result<(), Error> {
let mut operation = self.client().form(
Method::DELETE,
&format!("/topics/{topic_id}/workflows/{workflow_id}/stagings"),
)?;
operation.info(write_info(
"Workflows",
"DeleteWorkflowStaging",
"workflow_staging",
Some(topic_id),
));
self.client().send_unit(operation).await
}
async fn move_to_stage(
&self,
topic_id: i64,
workflow_id: i64,
stage_id: i64,
info: Option<OperationInfo>,
) -> Result<(), Error> {
let stage = stage_id.to_string();
let mut operation = self
.client()
.operation(&routes::MOVE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
operation
.form_representation()
.form(&[("workflow_staging[workflow_stage_id]", stage.as_str())]);
match info {
Some(info) => operation.info(info),
None => operation.quiet(),
};
self.client().send_unit(operation).await
}
}
fn summary(row: &[String]) -> Option<WorkflowSummary> {
match row {
[id, name, rest @ ..] => Some(WorkflowSummary {
id: id.parse().ok()?,
name: name.clone(),
account_name: rest.first().cloned().unwrap_or_default(),
}),
_ => None,
}
}