mod.rs
Language Rust9.8 KB274 lines
//! A conversation as a Markdown note with YAML frontmatter, for an Obsidian//! vault: title, dates, models, tokens and cost up top, then the exchange.use chrono::TimeZone;use crate::cost::to_usd;use crate::storage::{Conversation, Message};use crate::types::UsageSource;#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ImageLinks {/// `![[file.png]]`, Obsidian's embed.Wiki,/// ``, plain Markdown.Markdown,}pub struct ExportFile {/// Where to copy it, relative to the note's folder.pub relative_path: String,pub source_path: String,}pub struct Export {pub filename: String,pub markdown: String,pub files: Vec<ExportFile>,}/// A YAML double-quoted scalar, escaped.pub fn yaml_str(s: &str) -> String {let mut out = String::with_capacity(s.len() + 2);out.push('"');for c in s.chars() {match c {'"' => out.push_str("\\\""),'\\' => out.push_str("\\\\"),'\n' => out.push_str("\\n"),'\r' => out.push_str("\\r"),'\t' => out.push_str("\\t"),c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),c => out.push(c),}}out.push('"');out}/// A filename Obsidian and every filesystem accept.pub fn safe_filename(title: &str) -> String {let cleaned: String =title.chars().map(|c| if "[]#^|\\/:*?\"<>".contains(c) || c.is_control() { ' ' } else { c }).collect();let squashed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");let trimmed = squashed.trim_matches(|c: char| c == '.' || c == ' ');let mut name: String = trimmed.chars().take(80).collect();if name.is_empty() {name = "Cortex chat".into();}name}fn iso<Tz: TimeZone>(ms: i64, tz: &Tz) -> StringwhereTz::Offset: std::fmt::Display,{tz.timestamp_millis_opt(ms).single().map(|d| d.format("%Y-%m-%dT%H:%M:%S%:z").to_string()).unwrap_or_default()}pub fn to_markdown<Tz: TimeZone>(conv: &Conversation,messages: &[Message],attachment_paths: &dyn Fn(&str) -> Option<String>,links: ImageLinks,tz: &Tz,) -> ExportwhereTz::Offset: std::fmt::Display,{let mut unique: Vec<&str> = Vec::new();for m in messages.iter().filter(|m| m.role == "assistant").filter_map(|m| m.model_id.as_deref()) {if !unique.contains(&m) {unique.push(m);}}let assistant = || messages.iter().filter(|m| m.role == "assistant");let tokens_in: i64 = assistant().map(|m| m.usage.prompt_tokens).sum();let tokens_out: i64 = assistant().map(|m| m.usage.completion_tokens).sum();let cost: i64 = assistant().map(|m| m.cost_nanousd).sum();let estimated = assistant().any(|m| m.usage_source == UsageSource::Estimated);let mut fm = vec!["---".to_string(), format!("title: {}", yaml_str(&conv.title))];fm.push(format!("created: {}", iso(conv.created_at, tz)));fm.push(format!("updated: {}", iso(conv.updated_at, tz)));fm.push("models:".into());for m in &unique {fm.push(format!(" - {}", yaml_str(m)));}if unique.is_empty() {fm.pop();fm.push("models: []".into());}fm.push(format!("tokens_in: {tokens_in}"));fm.push(format!("tokens_out: {tokens_out}"));fm.push(format!("cost_usd: {:.6}", to_usd(cost)));fm.push(format!("cost_estimated: {estimated}"));fm.push(format!("cortex_id: {}", yaml_str(&conv.id)));fm.push("tags:".into());fm.push(" - cortex".into());fm.push("---".into());let mut body = vec![String::new(), format!("# {}", conv.title), String::new()];if !conv.system_prompt.trim().is_empty() {body.push("> [!note] System prompt".into());for line in conv.system_prompt.trim().lines() {body.push(format!("> {line}"));}body.push(String::new());}let mut files = Vec::new();let mut used_names: Vec<String> = Vec::new();for m in messages.iter().filter(|m| m.role != "system") {let who =if m.role == "user" { "You".to_string() } else { m.model_id.clone().unwrap_or_else(|| "Assistant".into()) };body.push(format!("## {who}"));body.push(String::new());if !m.content.trim().is_empty() {body.push(m.content.trim_end().to_string());body.push(String::new());}for a in &m.attachments {match a.kind.as_str() {"image" => {let Some(src) = attachment_paths(&a.id) else { continue };let mut name = safe_filename(&a.filename);let mut n = 2;while used_names.contains(&name) {name = format!("{n}-{}", safe_filename(&a.filename));n += 1;}used_names.push(name.clone());body.push(match links {ImageLinks::Wiki => format!("![[{name}]]"),ImageLinks::Markdown => format!("", name.replace(' ', "%20")),});body.push(String::new());files.push(ExportFile { relative_path: format!("attachments/{name}"), source_path: src });}_ => {body.push(format!("*Attached: {}*", a.filename));body.push(String::new());}}}match m.status.as_str() {"cancelled" => body.push("*Stopped before it finished.*\n".into()),"error" => body.push(format!("*Error: {}*\n", m.error_message.clone().unwrap_or_default())),_ => {}}}Export {filename: format!("{}.md", safe_filename(&conv.title)),markdown: format!("{}\n{}\n", fm.join("\n"), body.join("\n").trim_end()),files,}}#[cfg(test)]mod tests {use super::*;use crate::storage::{AttachmentMeta, Conversation, Message};use crate::types::{Usage, UsageSource};fn msg(role: &str, content: &str, model: Option<&str>, cost: i64, src: UsageSource) -> Message {Message {id: format!("m-{role}-{cost}"),conversation_id: "c1".into(),role: role.into(),content: content.into(),reasoning: None,status: "complete".into(),error_kind: None,error_message: None,provider_id: None,model_id: model.map(str::to_string),usage: if role == "assistant" {Usage { prompt_tokens: 120, completion_tokens: 80, cached_tokens: 0 }} else {Usage::default()},usage_source: src,prices: None,cost_nanousd: cost,finish_reason: None,ttft_ms: None,duration_ms: None,created_at: 0,attachments: vec![],}}#[test]fn a_whole_note() {let conv = Conversation {id: "0199-abc".into(),title: "Compare two \"pricing\" tiers: a/b".into(),provider_id: "deepinfra".into(),model_id: "m".into(),preset_id: None,system_prompt: "Be concise.\nUse tables.".into(),origin: "main".into(),pinned: false,archived: false,created_at: 1_790_000_000_000,updated_at: 1_790_000_600_000,};let mut user = msg("user", "Which tier is cheaper per seat?", None, 0, UsageSource::None);user.attachments.push(AttachmentMeta {id: "a1".into(),kind: "image".into(),filename: "chart: q3.png".into(),mime: "image/png".into(),byte_size: 10,width: Some(800),height: Some(600),});user.attachments.push(AttachmentMeta {id: "a2".into(),kind: "text".into(),filename: "notes.txt".into(),mime: "text/plain".into(),byte_size: 10,width: None,height: None,});let mut cut =msg("assistant", "Tier B, by about", Some("google/gemma-3-27b-it"), 1_000, UsageSource::Estimated);cut.status = "cancelled".into();let msgs = vec![user,msg("assistant","| Tier | Per seat |\n|---|---|\n| A | $12 |\n| B | $9 |",Some("Qwen/Qwen3-VL-30B-A3B-Instruct"),180_000,UsageSource::Reported,),msg("user", "And yearly?", None, 0, UsageSource::None),cut,];let paths = |id: &str| (id == "a1").then(|| "/data/attachments/abc.png".to_string());let tz = chrono::FixedOffset::west_opt(5 * 3600).unwrap();let wiki = to_markdown(&conv, &msgs, &paths, ImageLinks::Wiki, &tz);assert_eq!(wiki.filename, "Compare two pricing tiers a b.md");assert_eq!(wiki.files.len(), 1);assert_eq!(wiki.files[0].relative_path, "attachments/chart q3.png");insta::assert_snapshot!("export_wiki", wiki.markdown);let md = to_markdown(&conv, &msgs, &paths, ImageLinks::Markdown, &tz);assert!(md.markdown.contains(""));}#[test]fn yaml_escaping() {assert_eq!(yaml_str("a \"b\" \\ c\nd"), r#""a \"b\" \\ c\nd""#);assert_eq!(yaml_str("tab\there"), r#""tab\there""#);assert_eq!(yaml_str("\u{7}"), r#""\x07""#);}#[test]fn filenames() {assert_eq!(safe_filename(" ..weird [[name]] #tag ^x | y "), "weird name tag x y");assert_eq!(safe_filename("///"), "Cortex chat");assert_eq!(safe_filename(&"x".repeat(200)).len(), 80);}}