finery 0.12.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use serde_json::Value;

pub(super) fn clean_for_speed_reader(source: &str) -> String {
    let mut block = None;
    let mut in_code_block = false;
    let lines = source
        .lines()
        .filter_map(|line| {
            if line.trim_start().starts_with("```") {
                in_code_block = !in_code_block;
                return Some(line.to_owned());
            }
            if in_code_block {
                return Some(line.to_owned());
            }
            if let Some(label) = panel_label(line.trim()) {
                block = Some(JiraBlock::Panel);
                return Some(label);
            }
            if line.trim() == "{{jira:task-list}}" {
                block = Some(JiraBlock::TaskList);
                return None;
            }
            if line.trim() == "{{jira:decision-list}}" {
                block = Some(JiraBlock::DecisionList);
                return None;
            }
            if matches!(
                line.trim(),
                "{{/jira:panel}}" | "{{/jira:task-list}}" | "{{/jira:decision-list}}"
            ) {
                block = None;
                return None;
            }
            let line = clean_inline(line);
            Some(match block {
                Some(JiraBlock::TaskList) if line.starts_with("- [ ] ") => {
                    format!("- To do: {}", &line[6..])
                }
                Some(JiraBlock::TaskList) if line.starts_with("- [x] ") => {
                    format!("- Done: {}", &line[6..])
                }
                Some(JiraBlock::DecisionList) if line.starts_with("- ") => {
                    format!("- Decision: {}", &line[2..])
                }
                _ => line,
            })
        })
        .collect::<Vec<_>>();
    lines.join("\n").trim().to_owned()
}

#[derive(Clone, Copy)]
enum JiraBlock {
    Panel,
    TaskList,
    DecisionList,
}

fn panel_label(line: &str) -> Option<String> {
    let attrs = line
        .strip_prefix("{{jira:panel ")?
        .strip_suffix("}}")
        .and_then(|attrs| serde_json::from_str::<Value>(attrs).ok())?;
    let panel_type = attrs.get("panelType")?.as_str()?;
    Some(format!("{}:", title_case(panel_type)))
}

fn title_case(value: &str) -> String {
    let mut characters = value.chars();
    let Some(first) = characters.next() else {
        return String::new();
    };
    first.to_uppercase().collect::<String>() + characters.as_str()
}

fn clean_inline(source: &str) -> String {
    let mut output = String::new();
    let mut remaining = source;
    while !remaining.is_empty() {
        if let Some(rest) = remaining.strip_prefix('\\') {
            if let Some(character) = rest.chars().next() {
                output.push(character);
                remaining = &rest[character.len_utf8()..];
                continue;
            }
        }
        if let Some((code, rest)) = inline_code(remaining) {
            output.push_str(code);
            remaining = rest;
            continue;
        }
        if let Some((replacement, rest)) = jira_inline(remaining) {
            output.push_str(&replacement);
            remaining = rest;
            continue;
        }
        let character = remaining.chars().next().expect("source is not empty");
        output.push(character);
        remaining = &remaining[character.len_utf8()..];
    }
    output
}

fn inline_code(source: &str) -> Option<(&str, &str)> {
    let code = source.strip_prefix('`')?;
    let end = code.find('`')?;
    Some((&source[..end + 2], &code[end + 1..]))
}

fn jira_inline(source: &str) -> Option<(String, &str)> {
    date(source)
        .or_else(|| status(source))
        .or_else(|| mention(source))
        .or_else(|| card(source))
        .or_else(|| color(source))
        .or_else(|| underline(source))
        .or_else(|| emoji(source))
}

fn date(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("@date(")?;
    let end = value.find(')')?;
    Some((value[..end].to_owned(), &value[end + 1..]))
}

fn status(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("@status(")?;
    let (text, rest) = json_string(value)?;
    let rest = rest.strip_prefix(", ")?;
    let end = rest.find(')')?;
    Some((text, &rest[end + 1..]))
}

fn mention(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("@mention(")?;
    let (text, rest) = json_string(value)?;
    let rest = rest.strip_prefix(", ")?;
    let (_, rest) = json_string(rest)?;
    Some((text, rest.strip_prefix(')')?))
}

fn card(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("@card(")?;
    let end = value.find(')')?;
    Some((value[..end].to_owned(), &value[end + 1..]))
}

fn color(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("{color:")?;
    let color_end = value.find('}')?;
    let value = &value[color_end + 1..];
    let end = value.find("{/color}")?;
    Some((
        clean_inline(&value[..end]),
        &value[end + "{/color}".len()..],
    ))
}

fn underline(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix("++")?;
    let end = value.find("++")?;
    Some((clean_inline(&value[..end]), &value[end + 2..]))
}

fn emoji(source: &str) -> Option<(String, &str)> {
    let value = source.strip_prefix(':')?;
    let end = value.find(':')?;
    let name = &value[..end];
    (!name.is_empty()
        && name.chars().all(|character| {
            character.is_ascii_alphanumeric()
                || character == '_'
                || character == '-'
                || character == '+'
        }))
    .then(|| (name.replace(['_', '-'], " "), &value[end + 1..]))
}

fn json_string(source: &str) -> Option<(String, &str)> {
    let mut values = serde_json::Deserializer::from_str(source).into_iter::<String>();
    let value = values.next()?.ok()?;
    Some((value, &source[values.byte_offset()..]))
}