feat: stash composes closed by sidebar trips as drafts
This commit is contained in:
parent
f55821213d
commit
84650e50bd
5 changed files with 310 additions and 13 deletions
137
src/ops.rs
137
src/ops.rs
|
|
@ -78,12 +78,15 @@ pub fn snapshot(
|
|||
})
|
||||
}
|
||||
|
||||
/// The three folders, in sidebar order.
|
||||
/// The sidebar's folders, in its order. Drafts is carrier's own folder:
|
||||
/// it has no table in the sealed store, its rows come from the drafts
|
||||
/// sidecar, and it only appears in the sidebar once it holds one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Folder {
|
||||
Inbox,
|
||||
Requests,
|
||||
Sent,
|
||||
Drafts,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
|
|
@ -94,10 +97,110 @@ impl Folder {
|
|||
Folder::Inbox => "inbox",
|
||||
Folder::Requests => "requests",
|
||||
Folder::Sent => "sent",
|
||||
// Never reached: `handle` routes draft selection away from
|
||||
// the store before any table lookup happens.
|
||||
Folder::Drafts => "drafts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A compose set aside instead of sent, kept beside the store like the read
|
||||
/// marks — it is plaintext by nature until it is sealed on the way out.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
/// The message this draft replies to, as 64 hex characters.
|
||||
pub reply_to: Option<String>,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
/// A fresh draft id: 8 random bytes as hex, unique within the sidecar.
|
||||
pub fn draft_id() -> String {
|
||||
use rand_core::RngCore;
|
||||
let mut bytes = [0u8; 8];
|
||||
rand_core::OsRng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// One line per field, with backslash escapes so no field can break the
|
||||
/// line format: `\` becomes `\\`, a newline `\n`, a carriage return `\r`.
|
||||
fn line(text: &str) -> String {
|
||||
text.replace('\\', "\\\\")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
}
|
||||
|
||||
fn unline(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some(other) => out.push(other),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Four lines per draft: header, to, subject, body. The whole file is
|
||||
/// rewritten on every change, which a handful of drafts makes honest.
|
||||
pub fn save_drafts(path: &Path, drafts: &[Draft]) {
|
||||
let mut text = String::new();
|
||||
for draft in drafts {
|
||||
let reply_to = draft.reply_to.as_deref().unwrap_or("-");
|
||||
use std::fmt::Write;
|
||||
let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to);
|
||||
let _ = writeln!(text, "{}", line(&draft.to));
|
||||
let _ = writeln!(text, "{}", line(&draft.subject));
|
||||
let _ = writeln!(text, "{}", line(&draft.body));
|
||||
}
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
|
||||
/// Reads the drafts sidecar; a missing or torn tail only costs the
|
||||
/// incomplete record at the end.
|
||||
pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut drafts = Vec::new();
|
||||
for record in lines.chunks(4) {
|
||||
let [header, to, subject, body] = record else {
|
||||
break;
|
||||
};
|
||||
let mut parts = header.split_whitespace();
|
||||
let (Some("draft"), Some(id), Some(at), reply_to) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let Ok(at) = at.parse::<i64>() else {
|
||||
break;
|
||||
};
|
||||
let id = id.to_string();
|
||||
drafts.push(Draft {
|
||||
id,
|
||||
to: unline(to),
|
||||
subject: unline(subject),
|
||||
body: unline(body),
|
||||
reply_to: match reply_to {
|
||||
None | Some("-") => None,
|
||||
Some(other) => Some(other.to_string()),
|
||||
},
|
||||
at,
|
||||
});
|
||||
}
|
||||
drafts
|
||||
}
|
||||
|
||||
fn explain(e: Error) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
|
@ -535,6 +638,38 @@ mod tests {
|
|||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||
use fumi_core::store::now;
|
||||
|
||||
#[test]
|
||||
fn drafts_round_trip_through_the_sidecar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("drafts.txt");
|
||||
let drafts = vec![
|
||||
Draft {
|
||||
id: draft_id(),
|
||||
to: "smol://alice@example.org/KEY".into(),
|
||||
subject: "line breaks\nand \\backslashes\r".into(),
|
||||
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||
reply_to: Some("ab".repeat(32)),
|
||||
at: now(),
|
||||
},
|
||||
Draft {
|
||||
id: draft_id(),
|
||||
to: "bob@example.org".into(),
|
||||
subject: "no reply".into(),
|
||||
body: String::new(),
|
||||
reply_to: None,
|
||||
at: now(),
|
||||
},
|
||||
];
|
||||
save_drafts(&path, &drafts);
|
||||
assert_eq!(load_drafts(&path), drafts);
|
||||
|
||||
// A torn tail costs only its own incomplete record.
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
|
||||
std::fs::write(&path, torn).unwrap();
|
||||
assert_eq!(load_drafts(&path).len(), 1);
|
||||
}
|
||||
|
||||
fn master() -> [u8; KEY_LEN] {
|
||||
let mut seed = [0u8; KEY_LEN];
|
||||
for (i, b) in seed.iter_mut().enumerate() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue