use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug)]
pub struct GroupedMessage {
pub is_user: bool,
pub content: String,
pub bubble_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatData {
pub tabs: Vec<ChatTab>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatTab {
#[serde(rename = "tabId")]
pub tab_id: Option<String>,
#[serde(rename = "chatTitle")]
pub chat_title: Option<String>,
#[serde(rename = "lastSendTime")]
pub last_send_time: Option<i64>,
pub bubbles: Vec<ChatBubble>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatBubble {
#[serde(rename = "type")]
pub bubble_type: String,
pub id: Option<String>,
#[serde(rename = "messageType")]
pub message_type: Option<u32>,
#[serde(rename = "terminalSelections")]
pub terminal_selections: Option<Vec<serde_json::Value>>,
#[serde(rename = "fileSelections")]
pub file_selections: Option<Vec<serde_json::Value>>,
pub text: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
#[serde(flatten)]
pub extra_fields: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ComposerData {
#[serde(rename = "allComposers")]
pub all_composers: Vec<ComposerItem>,
#[serde(rename = "selectedComposerIds")]
pub selected_composer_ids: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ComposerItem {
#[serde(rename = "composerId")]
pub composer_id: String,
#[serde(rename = "createdAt")]
pub created_at: i64,
#[serde(rename = "lastUpdatedAt")]
pub last_updated_at: Option<i64>,
#[serde(rename = "unifiedMode")]
pub unified_mode: String,
pub name: Option<String>,
}
#[derive(Debug)]
pub struct ConversationSummary {
pub key: ConversationKey,
pub title: String,
pub last_message_time: DateTime<Utc>,
pub message_count: usize,
pub conversation_type: ConversationType,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ConversationKey {
Traditional(TraditionalConversationKey),
Composer(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TraditionalConversationKey {
TabId(String),
Fallback(String),
}
impl ConversationKey {
pub fn short_id(&self) -> String {
let raw = match self {
ConversationKey::Composer(id) => id.as_str(),
ConversationKey::Traditional(TraditionalConversationKey::TabId(id)) => id.as_str(),
ConversationKey::Traditional(TraditionalConversationKey::Fallback(id)) => id.as_str(),
};
raw.chars().take(8).collect::<String>()
}
}
#[derive(Debug, Clone)]
pub enum ConversationType {
Traditional, #[allow(dead_code)]
Composer(ComposerMode), }
#[derive(Debug, Serialize, Deserialize)]
pub struct ComposerBubble {
#[serde(rename = "bubbleId", default)]
pub bubble_id: Option<String>,
#[serde(rename = "type", default)]
pub bubble_type: Option<i32>,
#[serde(default)]
pub text: Option<String>,
#[serde(rename = "codeBlocks", default)]
pub code_blocks: Option<Vec<serde_json::Value>>,
#[serde(rename = "assistantSuggestedDiffs", default)]
pub assistant_suggested_diffs: Option<Vec<serde_json::Value>>,
#[serde(rename = "humanChanges", default)]
pub human_changes: Option<Vec<serde_json::Value>>,
#[serde(rename = "toolResults", default)]
pub tool_results: Option<Vec<serde_json::Value>>,
#[serde(rename = "richText", default)]
pub rich_text: Option<serde_json::Value>,
#[serde(rename = "contextPieces", default)]
pub context_pieces: Option<Vec<serde_json::Value>>,
#[serde(rename = "attachedCodeChunks", default)]
pub attached_code_chunks: Option<Vec<serde_json::Value>>,
#[serde(rename = "relevantFiles", default)]
pub relevant_files: Option<Vec<serde_json::Value>>,
#[serde(rename = "suggestedCodeBlocks", default)]
pub suggested_code_blocks: Option<Vec<serde_json::Value>>,
#[serde(rename = "gitDiffs", default)]
pub git_diffs: Option<Vec<serde_json::Value>>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug)]
pub struct ComposerWithBubbles {
pub composer_data: ComposerItem,
pub bubbles: Vec<ComposerBubble>,
}
#[derive(Debug, Clone)]
pub enum ComposerMode {
Chat, Agent, Edit, }
#[derive(Debug)]
pub enum ConversationExport {
Traditional(ChatTab),
Composer(ComposerWithBubbles),
}
#[derive(Debug)]
pub struct WorkspaceInfo {
pub db_path: std::path::PathBuf,
pub project_path: Option<std::path::PathBuf>,
pub project_name: String,
pub has_chat_data: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceMetadata {
pub folder: String,
}
impl ChatTab {
pub fn conversation_key(&self) -> ConversationKey {
if let Some(tab_id) = self
.tab_id
.as_ref()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
{
return ConversationKey::Traditional(TraditionalConversationKey::TabId(
tab_id.to_string(),
));
}
ConversationKey::Traditional(TraditionalConversationKey::Fallback(
derive_tab_fallback_key(self),
))
}
pub fn get_title(&self) -> String {
self.chat_title
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| t!("cursor.export.untitled_conversation").to_string())
}
pub fn get_last_send_time(&self) -> DateTime<Utc> {
let timestamp = self.last_send_time.unwrap_or(0);
DateTime::from_timestamp_millis(timestamp).unwrap_or_else(Utc::now)
}
pub fn to_markdown(&self) -> String {
let mut markdown = String::new();
markdown.push_str(&format!("# {}\n\n", self.get_title()));
markdown.push_str(&t!(
"cursor.export.last_updated",
time = self.get_last_send_time().format("%Y-%m-%d %H:%M:%S")
));
for (i, bubble) in self.bubbles.iter().enumerate() {
let content = self.extract_bubble_content(bubble);
if !content.trim().is_empty() {
let speaker = if bubble.bubble_type == "user" {
t!("cursor.export.user_message")
} else {
t!("cursor.export.ai_message")
};
markdown.push_str(&format!(
"## {} ({}): \n\n{}\n\n",
speaker,
t!("cursor.export.message_number", number = i + 1),
content
));
}
}
markdown.push_str("---\n\n");
markdown
}
fn extract_bubble_content(&self, bubble: &ChatBubble) -> String {
if let Some(text) = &bubble.text
&& !text.trim().is_empty()
{
return text.clone();
}
if let Some(terminal_selections) = &bubble.terminal_selections {
let mut content = Vec::new();
for selection in terminal_selections {
if let Some(text) = selection.get("text")
&& let Some(text_str) = text.as_str()
&& !text_str.trim().is_empty()
{
content.push(text_str);
}
}
if !content.is_empty() {
return content.join("\n");
}
}
"*empty message*".to_string()
}
}
fn derive_tab_fallback_key(tab: &ChatTab) -> String {
let mut hasher = DefaultHasher::new();
tab.chat_title.hash(&mut hasher);
tab.last_send_time.hash(&mut hasher);
tab.bubbles.len().hash(&mut hasher);
if let Some(first) = tab.bubbles.first() {
first.id.hash(&mut hasher);
first.created_at.hash(&mut hasher);
first.bubble_type.hash(&mut hasher);
}
if let Some(last) = tab.bubbles.last() {
last.id.hash(&mut hasher);
last.created_at.hash(&mut hasher);
last.bubble_type.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
impl ComposerItem {
pub fn conversation_key(&self) -> ConversationKey {
ConversationKey::Composer(self.composer_id.clone())
}
pub fn get_title(&self) -> String {
if let Some(name) = &self.name
&& !name.trim().is_empty()
{
return name.clone();
}
format!(
"{} - {}",
t!("cursor.export.composer_conversation"),
self.composer_id.chars().take(8).collect::<String>()
)
}
pub fn get_last_updated_time(&self) -> DateTime<Utc> {
let timestamp = self.last_updated_at.unwrap_or(self.created_at);
DateTime::from_timestamp_millis(timestamp).unwrap_or_else(Utc::now)
}
pub fn get_composer_mode(&self) -> ComposerMode {
match self.unified_mode.as_str() {
"chat" => ComposerMode::Chat,
"agent" => ComposerMode::Agent,
"edit" => ComposerMode::Edit,
_ => ComposerMode::Chat, }
}
}
impl ConversationExport {
pub fn to_markdown(&self) -> String {
match self {
ConversationExport::Traditional(tab) => tab.to_markdown(),
ConversationExport::Composer(composer) => composer.to_markdown(),
}
}
pub fn get_title(&self) -> String {
match self {
ConversationExport::Traditional(tab) => tab.get_title(),
ConversationExport::Composer(composer) => composer.composer_data.get_title(),
}
}
}
impl WorkspaceInfo {
pub fn display_name(&self) -> String {
if self.has_chat_data {
if let Some(project_path) = &self.project_path {
format!("🌟 {} ({})", self.project_name, project_path.display())
} else {
format!("🌟 {} [Unknown path]", self.project_name)
}
} else if self.project_path.is_some() {
format!(
"{} ({})",
self.project_name,
self.project_path.as_ref().unwrap().display()
)
} else {
format!("{} [Unknown path]", self.project_name)
}
}
}
impl ComposerBubble {
pub fn get_display_content(&self) -> String {
let mut content = Vec::new();
if self.is_user_message() {
if let Some(text) = &self.text
&& !text.trim().is_empty()
{
content.push(text.clone());
}
if content.is_empty()
&& let Some(rich_text) = &self.rich_text
&& let Ok(parsed) = serde_json::from_value::<serde_json::Value>(rich_text.clone())
&& let Some(extracted) = self.extract_text_from_rich_text(&parsed)
&& !extracted.trim().is_empty()
{
content.push(extracted);
}
} else {
if let Some(text) = &self.text
&& !text.trim().is_empty()
{
content.push(text.clone());
}
if content.is_empty()
&& let Some(tool_data) = self.extra.get("toolFormerData")
&& let Some(tool_summary) = self.extract_tool_summary(tool_data)
{
content.push(tool_summary);
}
}
if let Some(code_blocks) = &self.code_blocks
&& !code_blocks.is_empty()
{
content.push(format!(
"<details>\n<summary>📄 代码块 ({})</summary>\n\n*内容已折叠*\n\n</details>",
code_blocks.len()
));
}
if let Some(assistant_suggested_diffs) = &self.assistant_suggested_diffs
&& !assistant_suggested_diffs.is_empty()
{
content.push(format!(
"<details>\n<summary>🤖 AI建议差异 ({})</summary>\n\n*内容已折叠*\n\n</details>",
assistant_suggested_diffs.len()
));
}
if content.is_empty() {
format!("*空消息 (type: {:?})*", self.bubble_type)
} else {
content.join("\n\n")
}
}
fn extract_text_from_rich_text(&self, rich_text: &serde_json::Value) -> Option<String> {
fn extract_text_recursive(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Object(obj) => {
if let Some(text) = obj.get("text")
&& let Some(text_str) = text.as_str()
{
return text_str.to_string();
}
if let Some(children) = obj.get("children")
&& let Some(children_array) = children.as_array()
{
return children_array
.iter()
.map(extract_text_recursive)
.collect::<Vec<_>>()
.join("");
}
String::new()
}
serde_json::Value::Array(arr) => arr
.iter()
.map(extract_text_recursive)
.collect::<Vec<_>>()
.join(""),
_ => String::new(),
}
}
let extracted = extract_text_recursive(rich_text);
if extracted.trim().is_empty() {
None
} else {
Some(extracted)
}
}
fn extract_tool_summary(&self, tool_data: &serde_json::Value) -> Option<String> {
if let Some(tool_obj) = tool_data.as_object() {
let tool_name = tool_obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("未知工具");
let status = tool_obj
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("未知状态");
if let Some(result_str) = tool_obj.get("result").and_then(|v| v.as_str())
&& let Ok(result_obj) = serde_json::from_str::<serde_json::Value>(result_str)
{
match tool_name {
"read_file" => {
if let Some(contents) = result_obj.get("contents").and_then(|v| v.as_str())
{
let preview = if contents.len() > 200 {
format!("{}...", contents.chars().take(200).collect::<String>())
} else {
contents.to_string()
};
return Some(format!(
"🔍 **读取文件**: {status}\n\n```\n{preview}\n```"
));
}
}
"run_terminal_cmd" => {
if let Some(output) = result_obj.get("output").and_then(|v| v.as_str()) {
let preview = if output.len() > 300 {
format!("{}...", output.chars().take(300).collect::<String>())
} else {
output.to_string()
};
return Some(format!(
"💻 **执行命令**: {status}\n\n```\n{preview}\n```"
));
}
}
_ => {
return Some(format!("🔧 **{tool_name}**: {status}"));
}
}
}
return Some(format!("🔧 **{tool_name}**: {status}"));
}
None
}
pub fn is_user_message(&self) -> bool {
if let Some(bubble_type) = self.bubble_type {
return bubble_type == 1;
}
if self.extra.contains_key("toolFormerData") {
return false;
}
if self.extra.contains_key("usageUuid") {
return false;
}
if !self.code_blocks.as_deref().unwrap_or_default().is_empty() {
return false;
}
if !self
.assistant_suggested_diffs
.as_deref()
.unwrap_or_default()
.is_empty()
{
return false;
}
if !self.tool_results.as_deref().unwrap_or_default().is_empty() {
return false;
}
true
}
}
impl ComposerWithBubbles {
pub fn get_grouped_messages(&self) -> Vec<GroupedMessage> {
let mut grouped_messages = Vec::new();
let mut current_group: Option<GroupedMessage> = None;
for bubble in self.bubbles.iter() {
let is_user = bubble.is_user_message();
let content = bubble.get_display_content();
if content.trim().is_empty() {
continue;
}
match &mut current_group {
Some(group) => {
if is_user {
grouped_messages.push(current_group.take().unwrap());
current_group = Some(GroupedMessage {
is_user,
content,
bubble_count: 1,
});
} else if group.is_user {
grouped_messages.push(current_group.take().unwrap());
current_group = Some(GroupedMessage {
is_user,
content,
bubble_count: 1,
});
} else {
let should_split = self.should_split_ai_messages(group, bubble);
if should_split {
grouped_messages.push(current_group.take().unwrap());
current_group = Some(GroupedMessage {
is_user,
content,
bubble_count: 1,
});
} else {
if !group.content.trim().is_empty() && !content.trim().is_empty() {
group.content.push_str("\n\n");
}
group.content.push_str(&content);
group.bubble_count += 1;
}
}
}
None => {
current_group = Some(GroupedMessage {
is_user,
content,
bubble_count: 1,
});
}
}
}
if let Some(group) = current_group {
grouped_messages.push(group);
}
grouped_messages
}
fn should_split_ai_messages(
&self,
_current_group: &GroupedMessage,
_bubble: &ComposerBubble,
) -> bool {
false
}
pub fn to_markdown(&self) -> String {
let mut markdown = String::new();
let mode_display = match self.composer_data.get_composer_mode() {
ComposerMode::Chat => t!("cursor.export.composer_mode_chat"),
ComposerMode::Agent => t!("cursor.export.composer_mode_agent"),
ComposerMode::Edit => t!("cursor.export.composer_mode_edit"),
};
markdown.push_str(&format!(
"# {} [Composer {}]\n\n",
self.composer_data.get_title(),
mode_display
));
markdown.push_str(&t!(
"cursor.export.last_updated",
time = self
.composer_data
.get_last_updated_time()
.format("%Y-%m-%d %H:%M:%S")
));
markdown.push_str(&format!(
"\n**Composer ID:** {}\n",
self.composer_data.composer_id
));
markdown.push_str(&format!(
"**{}:** {}\n\n",
t!("cursor.export.composer_mode_label"),
self.composer_data.unified_mode
));
let grouped_messages = self.get_grouped_messages();
for (i, group) in grouped_messages.iter().enumerate() {
let speaker = if group.is_user {
t!("cursor.export.user_message")
} else {
t!("cursor.export.ai_message")
};
let message_info = if group.bubble_count > 1 {
format!(
"{} ({}, {} bubbles)",
speaker,
t!("cursor.export.message_number", number = i + 1),
group.bubble_count
)
} else {
format!(
"{} ({})",
speaker,
t!("cursor.export.message_number", number = i + 1)
)
};
markdown.push_str(&format!("## {}: \n\n{}\n\n", message_info, group.content));
}
markdown.push_str("---\n\n");
markdown
}
}