use chrono::{DateTime, Duration, Utc}; pub fn format_document_list_item( id: &str, title: &str, updated_at: DateTime, word_count: usize, ) -> serde_json::Value { serde_json::json!({ "id": id, "title": title, "updated_at": updated_at.to_rfc3339(), "updated_relative": format_relative_time(updated_at), "word_count": word_count }) } pub fn format_document_content( id: &str, title: &str, content: &str, created_at: DateTime, updated_at: DateTime, ) -> serde_json::Value { serde_json::json!({ "id": id, "title": title, "content": content, "created_at": created_at.to_rfc3339(), "updated_at": updated_at.to_rfc3339(), "word_count": count_words(content) }) } pub fn format_error(message: &str) -> serde_json::Value { serde_json::json!({ "error": message, "success": false }) } pub fn format_relative_time(dt: DateTime) -> String { let now = Utc::now(); let diff = now.signed_duration_since(dt); if diff < Duration::minutes(1) { "just now".to_string() } else if diff < Duration::hours(1) { let mins = diff.num_minutes(); format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" }) } else if diff < Duration::days(1) { let hours = diff.num_hours(); format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) } else if diff < Duration::days(7) { let days = diff.num_days(); format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) } else if diff < Duration::days(30) { let weeks = diff.num_weeks(); format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" }) } else { dt.format("%b %d, %Y").to_string() } } pub fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } pub fn strip_html(html: &str) -> String { let mut result = String::new(); let mut in_tag = false; for ch in html.chars() { match ch { '<' => in_tag = true, '>' => in_tag = false, _ if !in_tag => result.push(ch), _ => {} } } result .replace(" ", " ") .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") } pub fn html_to_markdown(html: &str) -> String { let mut md = html.to_string(); md = md.replace("", "**").replace("", "**"); md = md.replace("", "**").replace("", "**"); md = md.replace("", "*").replace("", "*"); md = md.replace("", "*").replace("", "*"); md = md.replace("", "_").replace("", "_"); md = md.replace("

", "# ").replace("

", "\n"); md = md.replace("

", "## ").replace("

", "\n"); md = md.replace("

", "### ").replace("

", "\n"); md = md.replace("

", "#### ").replace("

", "\n"); md = md.replace("
", "##### ").replace("
", "\n"); md = md.replace("
", "###### ").replace("
", "\n"); md = md.replace("
", "\n").replace("
", "\n").replace("
", "\n"); md = md.replace("

", "").replace("

", "\n\n"); md = md.replace("
  • ", "- ").replace("
  • ", "\n"); md = md.replace("
      ", "").replace("
    ", "\n"); md = md.replace("
      ", "").replace("
    ", "\n"); md = md.replace("
    ", "> ").replace("
    ", "\n"); md = md.replace("", "`").replace("", "`"); md = md.replace("
    ", "```\n").replace("
    ", "\n```\n"); md = md.replace("
    ", "\n---\n").replace("
    ", "\n---\n"); strip_html(&md) } pub fn markdown_to_html(md: &str) -> String { let mut html = String::new(); let lines: Vec<&str> = md.lines().collect(); let mut in_code_block = false; let mut in_list = false; for line in lines { if line.starts_with("```") { if in_code_block { html.push_str(""); in_code_block = false; } else { html.push_str("
    ");
                    in_code_block = true;
                }
                continue;
            }
    
            if in_code_block {
                html.push_str(&html_escape(line));
                html.push('\n');
                continue;
            }
    
            let processed = process_markdown_line(line);
    
            if line.starts_with("- ") || line.starts_with("* ") {
                if !in_list {
                    html.push_str("
      "); in_list = true; } html.push_str(&format!("
    • {}
    • ", &processed[2..])); } else { if in_list { html.push_str("
    "); in_list = false; } html.push_str(&processed); } } if in_list { html.push_str(""); } if in_code_block { html.push_str("
    "); } html } fn process_markdown_line(line: &str) -> String { let mut result = line.to_string(); if line.starts_with("# ") { return format!("

    {}

    ", &line[2..]); } else if line.starts_with("## ") { return format!("

    {}

    ", &line[3..]); } else if line.starts_with("### ") { return format!("

    {}

    ", &line[4..]); } else if line.starts_with("#### ") { return format!("

    {}

    ", &line[5..]); } else if line.starts_with("##### ") { return format!("
    {}
    ", &line[6..]); } else if line.starts_with("###### ") { return format!("
    {}
    ", &line[7..]); } else if line.starts_with("> ") { return format!("
    {}
    ", &line[2..]); } else if line == "---" || line == "***" || line == "___" { return "
    ".to_string(); } result = process_inline_formatting(&result); if !result.is_empty() && !result.starts_with('<') { result = format!("

    {}

    ", result); } result } fn process_inline_formatting(text: &str) -> String { let mut result = text.to_string(); let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").ok(); if let Some(re) = bold_re { result = re.replace_all(&result, "$1").to_string(); } let italic_re = regex::Regex::new(r"\*(.+?)\*").ok(); if let Some(re) = italic_re { result = re.replace_all(&result, "$1").to_string(); } let code_re = regex::Regex::new(r"`(.+?)`").ok(); if let Some(re) = code_re { result = re.replace_all(&result, "$1").to_string(); } let link_re = regex::Regex::new(r"\[(.+?)\]\((.+?)\)").ok(); if let Some(re) = link_re { result = re.replace_all(&result, r#"$1"#).to_string(); } result } pub fn count_words(text: &str) -> usize { let plain_text = strip_html(text); plain_text .split_whitespace() .filter(|s| !s.is_empty()) .count() } pub fn truncate_text(text: &str, max_chars: usize) -> String { if text.len() <= max_chars { return text.to_string(); } let truncated: String = text.chars().take(max_chars).collect(); if let Some(last_space) = truncated.rfind(' ') { format!("{}...", &truncated[..last_space]) } else { format!("{}...", truncated) } } pub fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else if c == ' ' { '_' } else { '_' } }) .collect::() .trim_matches('_') .to_string() } pub fn generate_document_id() -> String { uuid::Uuid::new_v4().to_string() } pub fn get_user_docs_path(user_id: &str) -> String { format!("users/{}/docs", user_id) } pub fn rtf_to_html(rtf: &str) -> String { let mut html = String::new(); let mut in_group = 0; let mut bold = false; let mut italic = false; let mut underline = false; let mut skip_chars = 0; let chars: Vec = rtf.chars().collect(); let mut i = 0; html.push_str("
    "); while i < chars.len() { if skip_chars > 0 { skip_chars -= 1; i += 1; continue; } let ch = chars[i]; match ch { '{' => in_group += 1, '}' => in_group -= 1, '\\' => { let mut cmd = String::new(); i += 1; while i < chars.len() && chars[i].is_ascii_alphabetic() { cmd.push(chars[i]); i += 1; } match cmd.as_str() { "b" => { if !bold { html.push_str(""); bold = true; } } "b0" => { if bold { html.push_str(""); bold = false; } } "i" => { if !italic { html.push_str(""); italic = true; } } "i0" => { if italic { html.push_str(""); italic = false; } } "ul" => { if !underline { html.push_str(""); underline = true; } } "ulnone" => { if underline { html.push_str(""); underline = false; } } "par" | "line" => html.push_str("
    "), "tab" => html.push_str("    "), _ => {} } if i < chars.len() && chars[i] == ' ' { i += 1; } continue; } '\n' | '\r' => {} _ => { if in_group <= 1 { html.push(ch); } } } i += 1; } if underline { html.push_str(""); } if italic { html.push_str(""); } if bold { html.push_str(""); } html.push_str("
    "); html } pub fn html_to_rtf(html: &str) -> String { let mut rtf = String::from("{\\rtf1\\ansi\\deff0\n"); rtf.push_str("{\\fonttbl{\\f0 Arial;}}\n"); rtf.push_str("\\f0\\fs24\n"); let mut result = html.to_string(); result = result.replace("", "\\b "); result = result.replace("", "\\b0 "); result = result.replace("", "\\b "); result = result.replace("", "\\b0 "); result = result.replace("", "\\i "); result = result.replace("", "\\i0 "); result = result.replace("", "\\i "); result = result.replace("", "\\i0 "); result = result.replace("", "\\ul "); result = result.replace("", "\\ulnone "); result = result.replace("
    ", "\\par\n"); result = result.replace("
    ", "\\par\n"); result = result.replace("
    ", "\\par\n"); result = result.replace("

    ", ""); result = result.replace("

    ", "\\par\\par\n"); result = result.replace("

    ", "\\fs48\\b "); result = result.replace("

    ", "\\b0\\fs24\\par\n"); result = result.replace("

    ", "\\fs36\\b "); result = result.replace("

    ", "\\b0\\fs24\\par\n"); result = result.replace("

    ", "\\fs28\\b "); result = result.replace("

    ", "\\b0\\fs24\\par\n"); let stripped = strip_html(&result); rtf.push_str(&stripped); rtf.push('}'); rtf } pub fn odt_content_to_html(odt_xml: &str) -> String { let mut html = String::from("
    "); let mut in_text = false; let mut in_span = false; let mut current_text = String::new(); let chars: Vec = odt_xml.chars().collect(); let mut i = 0; while i < chars.len() { if chars[i] == '<' { let mut tag = String::new(); i += 1; while i < chars.len() && chars[i] != '>' { tag.push(chars[i]); i += 1; } if tag.starts_with("text:p") { if !current_text.is_empty() { html.push_str(¤t_text); current_text.clear(); } html.push_str("

    "); in_text = true; } else if tag == "/text:p" { html.push_str(¤t_text); current_text.clear(); html.push_str("

    "); in_text = false; } else if tag.starts_with("text:span") { if tag.contains("Bold") { html.push_str(""); } else if tag.contains("Italic") { html.push_str(""); } in_span = true; } else if tag == "/text:span" { html.push_str(¤t_text); current_text.clear(); if in_span { html.push_str(""); } in_span = false; } else if tag.starts_with("text:h") { let level = tag.chars() .find(|c| c.is_ascii_digit()) .unwrap_or('1'); html.push_str(&format!("")); in_text = true; } else if tag.starts_with("/text:h") { html.push_str(¤t_text); current_text.clear(); html.push_str(""); in_text = false; } else if tag == "text:line-break" || tag == "text:line-break/" { current_text.push_str("
    "); } else if tag == "text:tab" || tag == "text:tab/" { current_text.push_str("    "); } } else if in_text { current_text.push(chars[i]); } i += 1; } if !current_text.is_empty() { html.push_str(¤t_text); } html.push_str("
    "); html } pub fn html_to_odt_content(html: &str) -> String { let mut odt = String::from(r#" "#); let mut result = html.to_string(); result = result.replace("

    ", ""); result = result.replace("

    ", "\n"); result = result.replace("
    ", ""); result = result.replace("
    ", ""); result = result.replace("
    ", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("", ""); result = result.replace("

    ", ""); result = result.replace("

    ", "\n"); result = result.replace("

    ", ""); result = result.replace("

    ", "\n"); result = result.replace("

    ", ""); result = result.replace("

    ", "\n"); let stripped = strip_html(&result); let paragraphs: Vec<&str> = stripped.lines().collect(); for para in paragraphs { if !para.trim().is_empty() { odt.push_str(&format!("{}\n", para.trim())); } } odt.push_str("
    \n
    \n
    "); odt } pub fn detect_document_format(content: &[u8]) -> &'static str { if content.len() >= 4 { if &content[0..4] == b"PK\x03\x04" { if content.len() > 30 { let content_str = String::from_utf8_lossy(&content[0..100.min(content.len())]); if content_str.contains("word/") { return "docx"; } else if content_str.contains("content.xml") { return "odt"; } } return "zip"; } if &content[0..4] == b"{\\rt" { return "rtf"; } if content[0] == 0xD0 && content[1] == 0xCF { return "doc"; } } let text = String::from_utf8_lossy(content); if text.trim_start().starts_with(" Result { let format = detect_document_format(content); let text = String::from_utf8_lossy(content).to_string(); match format { "rtf" => Ok(rtf_to_html(&text)), "html" => Ok(text), "markdown" => Ok(markdown_to_html(&text)), "txt" => Ok(format!("

    {}

    ", html_escape(&text).replace('\n', "

    "))), _ => Err(format!("Unsupported format: {format}")), } }