use super::*;
#[derive(Clone, Debug, Serialize)]
pub struct InboxMessageRecord {
pub message: Value,
pub view: InboxMessageView,
}
#[derive(Clone, Debug, Serialize)]
pub struct InboxMessageView {
pub title: String,
pub from: String,
pub to: Vec<String>,
pub received_rfc3339: String,
pub attachment_count: usize,
pub status: String,
pub body_text: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CaseSummaryRecord {
pub case: Value,
pub view: CaseSummaryView,
}
#[derive(Clone, Debug, Serialize)]
pub struct CaseSummaryView {
pub group: String,
pub case_dir: String,
pub updated_rfc3339: String,
pub last_message_rfc3339: String,
pub notes_text: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveCaseSummaryRecord {
pub case: Value,
pub view: ArchiveCaseSummaryView,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveCaseSummaryView {
pub case_dir: String,
pub updated_rfc3339: String,
pub notes_text: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveItemRecord {
pub item: Value,
pub message: Value,
pub view: ArchiveItemView,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveItemView {
pub summary: String,
pub added_rfc3339: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveCategoryRecord {
pub archive: Value,
pub view: ArchiveCategoryView,
pub items: Vec<ArchiveItemRecord>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchiveCategoryView {
pub archive_dir: String,
pub updated_rfc3339: String,
pub notes_text: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PushSummaryRecord {
pub push: Value,
pub view: PushSummaryView,
}
#[derive(Clone, Debug, Serialize)]
pub struct PushSummaryView {
pub display_kind: String,
pub step_count: usize,
}
impl Workspace {
pub(crate) fn inbox_message_views(&self, triage: &Value) -> Result<Vec<InboxMessageRecord>> {
let mut out = Vec::new();
for item in value_array(triage, "items") {
let Some(message_id) = item.get("message_id").and_then(Value::as_str) else {
continue;
};
let message = self.read_message_by_id(message_id)?;
let message_value =
read_json_file(&self.message_path(message_id), "read message json")?;
out.push(InboxMessageRecord {
view: InboxMessageView {
title: non_empty(message.subject.as_deref())
.unwrap_or_else(|| message_id.to_string()),
from: message.from.clone().unwrap_or_default(),
to: message.to.clone(),
received_rfc3339: message.received_rfc3339.clone().unwrap_or_default(),
attachment_count: message.attachments.len(),
status: message.workspace.status.clone(),
body_text: message.body_text.clone(),
},
message: message_value,
});
}
Ok(out)
}
pub(crate) fn case_summary_views(&self, cases: &Value) -> Result<Vec<CaseSummaryRecord>> {
let mut out = Vec::new();
for item in value_array(cases, "items") {
let (Some(group), Some(case_dir)) = (
item.get("group").and_then(Value::as_str),
item.get("case_dir").and_then(Value::as_str),
) else {
continue;
};
let path = self.root.join("cases").join(group).join(case_dir);
let case = read_case_file(&path)?;
let notes_text = read_optional_string(&path.join("notes.md"))?.unwrap_or_default();
let case_value = read_json_file(&case_json_path(&path), "read case metadata")?;
out.push(CaseSummaryRecord {
case: case_value,
view: CaseSummaryView {
group: group.to_string(),
case_dir: case_dir.to_string(),
updated_rfc3339: case.updated_rfc3339.unwrap_or_default(),
last_message_rfc3339: case.last_message_rfc3339.unwrap_or_default(),
notes_text,
},
});
}
Ok(out)
}
pub(crate) fn archive_case_summary_views(
&self,
archive: &Value,
) -> Result<Vec<ArchiveCaseSummaryRecord>> {
let mut out = Vec::new();
for item in value_array(archive, "cases") {
let Some(case_dir) = item.get("case_dir").and_then(Value::as_str) else {
continue;
};
let path = self.root.join("archive/cases").join(case_dir);
let case = read_case_file(&path)?;
let notes_text = read_optional_string(&path.join("notes.md"))?.unwrap_or_default();
let updated_rfc3339 = case
.updated_rfc3339
.clone()
.or_else(|| case.archived_rfc3339.clone())
.unwrap_or_default();
let case_value = read_json_file(&case_json_path(&path), "read archived case metadata")?;
out.push(ArchiveCaseSummaryRecord {
case: case_value,
view: ArchiveCaseSummaryView {
case_dir: case_dir.to_string(),
updated_rfc3339,
notes_text,
},
});
}
Ok(out)
}
pub(crate) fn archive_category_views(
&self,
archive: &Value,
) -> Result<Vec<ArchiveCategoryRecord>> {
let mut out = Vec::new();
for item in value_array(archive, "messages") {
let Some(archive_uid) = item.get("archive_uid").and_then(Value::as_str) else {
continue;
};
let data = self.read_archive_messages(archive_uid)?;
let archive_value = read_json_file(
&self.archive_message_json_path(archive_uid),
"read archive messages",
)?;
let dir = self.archive_message_dir(archive_uid);
let archive_dir = dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let notes_text = read_optional_string(&dir.join("notes.md"))?.unwrap_or_default();
let updated_rfc3339 = data
.items
.iter()
.map(|entry| entry.added_rfc3339.as_str())
.max()
.unwrap_or_default()
.to_string();
let raw_items = archive_value
.get("items")
.and_then(Value::as_array)
.ok_or_else(|| {
AppError::new(
"archive_messages_invalid",
"archive messages JSON must contain an items array",
)
})?;
let mut items = Vec::new();
for (index, entry) in data.items.iter().enumerate() {
self.read_message_by_id(&entry.message_id)?;
let message_value =
read_json_file(&self.message_path(&entry.message_id), "read message json")?;
let item_value = raw_items.get(index).cloned().ok_or_else(|| {
AppError::new(
"archive_messages_invalid",
"archive messages JSON item count changed while building UI state",
)
})?;
items.push(ArchiveItemRecord {
item: item_value,
message: message_value,
view: ArchiveItemView {
summary: entry.summary.clone().unwrap_or_default(),
added_rfc3339: entry.added_rfc3339.clone(),
},
});
}
out.push(ArchiveCategoryRecord {
archive: archive_value,
view: ArchiveCategoryView {
archive_dir,
updated_rfc3339,
notes_text,
},
items,
});
}
Ok(out)
}
pub(crate) fn push_summary_views(&self, push: &Value) -> Result<Vec<PushSummaryRecord>> {
let mut out = Vec::new();
for item in value_array(push, "items") {
out.push(PushSummaryRecord {
push: item.clone(),
view: PushSummaryView {
display_kind: item
.get("display_kind")
.or_else(|| item.get("action"))
.or_else(|| item.get("kind"))
.and_then(Value::as_str)
.unwrap_or("push item")
.to_string(),
step_count: item
.get("step_states")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
},
});
}
Ok(out)
}
}
fn non_empty(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn value_array<'a>(value: &'a Value, key: &str) -> &'a [Value] {
value
.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn read_json_file(path: &Path, context: &str) -> Result<Value> {
let data = read_to_string(path, context)?;
serde_json::from_str(&data).map_err(|e| AppError::json(context, &e))
}
fn read_optional_string(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(AppError::io("read view-model text", &err)),
}
}