feat: extract all user-facing strings into a Fluent catalogue
This commit is contained in:
parent
4c5665a6ff
commit
57e75af3ac
8 changed files with 1024 additions and 326 deletions
59
src/app.rs
59
src/app.rs
|
|
@ -15,6 +15,7 @@ use fumi_core::crypto::{fingerprint, unb32, KEY_LEN};
|
|||
use fumi_core::store::Store;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -514,7 +515,7 @@ impl Carrier {
|
|||
at: fumi_core::store::now(),
|
||||
});
|
||||
ops::save_drafts(&self.drafts_path(), &self.drafts);
|
||||
self.note("draft saved".into());
|
||||
self.note(i18n::t("draft-saved-note"));
|
||||
}
|
||||
|
||||
/// Tapping a draft in the folder puts it back in the compose it came
|
||||
|
|
@ -555,7 +556,7 @@ impl Carrier {
|
|||
}
|
||||
let (app, _) = Carrier::boot(self.dir.clone());
|
||||
*self = app;
|
||||
self.note("logged out. Create a new identity or restore a seed.".into());
|
||||
self.note(i18n::t("logged-out-note"));
|
||||
}
|
||||
|
||||
/// Cancelling out of a not-yet-registered identity removes the key
|
||||
|
|
@ -853,9 +854,7 @@ impl Carrier {
|
|||
self.master = Some(master);
|
||||
self.onboard.seed_shown = ops::seed_text(&master);
|
||||
self.onboard.mode = OnboardMode::Created;
|
||||
self.note(
|
||||
"Identity created. Write the seed down; it is the only secret.".into(),
|
||||
);
|
||||
self.note(i18n::t("identity-created-note"));
|
||||
}
|
||||
}
|
||||
Message::OnboardRestore => {
|
||||
|
|
@ -877,7 +876,7 @@ impl Carrier {
|
|||
} else {
|
||||
self.master = Some(master);
|
||||
self.page = Page::Signup;
|
||||
self.note("Seed restored. Bind a username or recall an address.".into());
|
||||
self.note(i18n::t("seed-restored-note"));
|
||||
}
|
||||
}
|
||||
Err(e) => self.fail(e),
|
||||
|
|
@ -904,14 +903,14 @@ impl Carrier {
|
|||
return Vec::new();
|
||||
}
|
||||
let Some(addr) = self.signup_host_address() else {
|
||||
self.fail("enter the server host first".into());
|
||||
self.fail(i18n::t("signup-enter-host-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
return vec![PendingOp::LearnKey { addr }];
|
||||
}
|
||||
Message::SignupRegister => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host".into());
|
||||
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
let invite = if self.signup.invite.is_empty() {
|
||||
|
|
@ -926,7 +925,7 @@ impl Carrier {
|
|||
}
|
||||
Message::SignupRecall => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host".into());
|
||||
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
if !self.start("restoring") {
|
||||
|
|
@ -993,7 +992,7 @@ impl Carrier {
|
|||
let to = if let Some(uri) = &msg.reply_to {
|
||||
uri.clone()
|
||||
} else if msg.from.starts_with('<') {
|
||||
self.fail("this sender left no address to reply to".into());
|
||||
self.fail(i18n::t("reply-no-address-error"));
|
||||
return Vec::new();
|
||||
} else {
|
||||
msg.from.clone()
|
||||
|
|
@ -1001,7 +1000,7 @@ impl Carrier {
|
|||
let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") {
|
||||
msg.subject.clone()
|
||||
} else {
|
||||
format!("Re: {}", msg.subject)
|
||||
i18n::t1("reply-subject-prefix", "subject", msg.subject.clone())
|
||||
};
|
||||
// The sender's own fields are the one thing a reply has to
|
||||
// decide about: carry copies, or start clean. Only the
|
||||
|
|
@ -1057,7 +1056,7 @@ impl Carrier {
|
|||
return Vec::new();
|
||||
};
|
||||
if compose.to.trim().is_empty() {
|
||||
self.fail("enter a recipient".into());
|
||||
self.fail(i18n::t("compose-enter-recipient-error"));
|
||||
return Vec::new();
|
||||
}
|
||||
let mut headers = Vec::new();
|
||||
|
|
@ -1066,7 +1065,10 @@ impl Carrier {
|
|||
Ok(Some(pair)) => headers.push(pair),
|
||||
Ok(None) => {}
|
||||
Err(reason) => {
|
||||
self.fail(format!("header {}: {reason}", index + 1));
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("index", (index + 1) as i64);
|
||||
args.set("reason", reason);
|
||||
self.fail(i18n::t_args("compose-header-row-error", Some(&args)));
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
|
@ -1109,11 +1111,10 @@ impl Carrier {
|
|||
}
|
||||
if self.strict_pinning {
|
||||
if let Some(addr) = self.unpinned_first_contact(&to) {
|
||||
self.fail(format!(
|
||||
"{} is not a contact yet and {} is not pinned — pin their server's key to send.",
|
||||
addr.short(),
|
||||
trust_label(&addr.host, addr.port),
|
||||
));
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("address", addr.short());
|
||||
args.set("host", trust_label(&addr.host, addr.port));
|
||||
self.fail(i18n::t_args("first-contact-blocked-error", Some(&args)));
|
||||
self.pin_prompt = Some(PinPrompt {
|
||||
addr,
|
||||
offered: None,
|
||||
|
|
@ -1139,7 +1140,7 @@ impl Carrier {
|
|||
return Vec::new();
|
||||
};
|
||||
let Ok(addr) = Address::parse(&to) else {
|
||||
self.fail("that address does not parse".into());
|
||||
self.fail(i18n::t("address-invalid-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
let fetched = self
|
||||
|
|
@ -1203,17 +1204,17 @@ impl Carrier {
|
|||
return Vec::new();
|
||||
}
|
||||
let Some(uri) = msg.reply_to.clone() else {
|
||||
self.fail("this sender left no address to accept".into());
|
||||
self.fail(i18n::t("contacts-accept-no-address-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(addr) = Address::parse(&uri) else {
|
||||
self.fail("the sender's reply address does not parse".into());
|
||||
self.fail(i18n::t("contacts-reply-address-invalid-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(identity) =
|
||||
<[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default())
|
||||
else {
|
||||
self.fail("the sender's key does not parse".into());
|
||||
self.fail(i18n::t("contacts-sender-key-invalid-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
return vec![PendingOp::Accept {
|
||||
|
|
@ -1226,11 +1227,11 @@ impl Carrier {
|
|||
return Vec::new();
|
||||
};
|
||||
let Some(uri) = msg.reply_to.clone() else {
|
||||
self.fail("this sender left no address to block".into());
|
||||
self.fail(i18n::t("contacts-block-no-address-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(addr) = Address::parse(&uri) else {
|
||||
self.fail("the sender's reply address does not parse".into());
|
||||
self.fail(i18n::t("contacts-reply-address-invalid-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
return vec![PendingOp::Block {
|
||||
|
|
@ -1414,11 +1415,7 @@ impl Carrier {
|
|||
Some(prompt) => prompt.offered = Some(key),
|
||||
None => self.pin_offer = Some((addr, key)),
|
||||
}
|
||||
self.note(
|
||||
"The server presented this key. Pin it only if the fingerprint \
|
||||
matches what the operator published."
|
||||
.into(),
|
||||
);
|
||||
self.note(i18n::t("server-key-offered-note"));
|
||||
None
|
||||
}
|
||||
OpResult::PinOffered(Ok(None)) => {
|
||||
|
|
@ -1428,7 +1425,7 @@ impl Carrier {
|
|||
if self.pin_prompt.take().is_none() {
|
||||
self.signup.key_trusted = true;
|
||||
}
|
||||
self.note("This server's key is already pinned.".into());
|
||||
self.note(i18n::t("server-key-already-pinned-note"));
|
||||
None
|
||||
}
|
||||
OpResult::PinOffered(Err(e)) => {
|
||||
|
|
@ -1438,7 +1435,7 @@ impl Carrier {
|
|||
}
|
||||
OpResult::Pinned(Ok(())) => {
|
||||
self.done();
|
||||
self.note("server key pinned".into());
|
||||
self.note(i18n::t("server-key-pinned-note"));
|
||||
// The contact detail shows the pin state, so that pane
|
||||
// needs the fresh row; signup's own pin changes nothing
|
||||
// that is on screen.
|
||||
|
|
|
|||
64
src/i18n.rs
Normal file
64
src/i18n.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! A single Fluent bundle, loaded once from `locales/en-US/main.ftl` and
|
||||
//! baked into the binary via `include_str!`. Every user-facing string in
|
||||
//! the app is looked up through `t`/`t1`/`t_args` rather than written
|
||||
//! inline, so copy review and translation both have one place to work
|
||||
//! from. There is only one locale today; a future one would plug in here
|
||||
//! without touching a call site.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use fluent_bundle::concurrent::FluentBundle;
|
||||
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
|
||||
|
||||
fn bundle() -> &'static FluentBundle<FluentResource> {
|
||||
static BUNDLE: OnceLock<FluentBundle<FluentResource>> = OnceLock::new();
|
||||
BUNDLE.get_or_init(|| {
|
||||
let ftl = include_str!("../locales/en-US/main.ftl");
|
||||
let resource =
|
||||
FluentResource::try_new(ftl.to_string()).expect("main.ftl must parse as Fluent");
|
||||
let locale = "en-US".parse().expect("\"en-US\" is a valid language tag");
|
||||
let mut bundle = FluentBundle::new_concurrent(vec![locale]);
|
||||
// Bidi isolation marks are invisible but real characters; with a
|
||||
// single left-to-right locale they only cost correctness —
|
||||
// copy/paste, clipboard round-trips and plain string equality all
|
||||
// pick them up — for no visible benefit.
|
||||
bundle.set_use_isolating(false);
|
||||
bundle
|
||||
.add_resource(resource)
|
||||
.expect("main.ftl must not redefine a message id");
|
||||
bundle
|
||||
})
|
||||
}
|
||||
|
||||
/// Looks up a message with no variables.
|
||||
pub fn t(id: &str) -> String {
|
||||
t_args(id, None)
|
||||
}
|
||||
|
||||
/// Looks up a message with one variable — the common case, since most
|
||||
/// interpolated strings here carry a single value.
|
||||
pub fn t1(id: &str, key: &'static str, value: impl Into<FluentValue<'static>>) -> String {
|
||||
let mut args = FluentArgs::new();
|
||||
args.set(key, value);
|
||||
t_args(id, Some(&args))
|
||||
}
|
||||
|
||||
/// Looks up a message against a caller-built argument set, for the rare
|
||||
/// string that interpolates more than one value.
|
||||
pub fn t_args(id: &str, args: Option<&FluentArgs>) -> String {
|
||||
let bundle = bundle();
|
||||
// A missing id or a value-less term is a copy bug, not a runtime
|
||||
// condition to hide: surface it in the string itself rather than
|
||||
// silently falling back to English prose that isn't there to fall
|
||||
// back to (there's only the one locale).
|
||||
let Some(message) = bundle.get_message(id) else {
|
||||
return format!("???{id}???");
|
||||
};
|
||||
let Some(pattern) = message.value() else {
|
||||
return format!("???{id}???");
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
bundle
|
||||
.format_pattern(pattern, args, &mut errors)
|
||||
.into_owned()
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
mod app;
|
||||
#[cfg(debug_assertions)]
|
||||
mod demo;
|
||||
mod i18n;
|
||||
mod ops;
|
||||
mod view;
|
||||
|
||||
|
|
|
|||
142
src/ops.rs
142
src/ops.rs
|
|
@ -15,6 +15,8 @@ use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX};
|
|||
use fumi_core::store::Store;
|
||||
use fumi_core::{account, client, export};
|
||||
|
||||
use crate::i18n;
|
||||
|
||||
/// One message in a folder list, already opened for its metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
|
|
@ -316,6 +318,19 @@ fn explain(e: Error) -> String {
|
|||
e.to_string()
|
||||
}
|
||||
|
||||
/// `{ $path }: { $reason }` — the one shared shape every local file error
|
||||
/// here takes; `reason` is whatever the OS says, which this catalogue
|
||||
/// cannot translate, so only the separator around it is localized.
|
||||
fn file_error_args(
|
||||
path: &Path,
|
||||
reason: &dyn std::fmt::Display,
|
||||
) -> fluent_bundle::FluentArgs<'static> {
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("path", path.display().to_string());
|
||||
args.set("reason", reason.to_string());
|
||||
args
|
||||
}
|
||||
|
||||
pub fn new_master() -> [u8; KEY_LEN] {
|
||||
use rand_core::RngCore;
|
||||
let mut master = [0u8; KEY_LEN];
|
||||
|
|
@ -333,23 +348,26 @@ pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> {
|
|||
let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let mut raw = Vec::with_capacity(32);
|
||||
for pair in text.as_bytes().chunks(2) {
|
||||
let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?;
|
||||
let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?;
|
||||
let hi = (pair[0] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| i18n::t("seed-bad-hex"))?;
|
||||
let lo = (pair[1] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| i18n::t("seed-bad-hex"))?;
|
||||
raw.push((hi * 16 + lo) as u8);
|
||||
}
|
||||
raw
|
||||
} else {
|
||||
unb32(text).map_err(|e| explain(e))?
|
||||
};
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| "a seed is 32 bytes".to_string())
|
||||
bytes.try_into().map_err(|_| i18n::t("seed-wrong-length"))
|
||||
}
|
||||
|
||||
pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let raw = std::fs::read(path)
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(path, &e))))?;
|
||||
raw.try_into()
|
||||
.map_err(|_| format!("{}: not a 32-byte master", path.display()))
|
||||
.map_err(|_| i18n::t1("key-file-wrong-length", "path", path.display().to_string()))
|
||||
}
|
||||
|
||||
pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> {
|
||||
|
|
@ -377,7 +395,7 @@ pub fn delete_key(path: &Path) -> Result<(), String> {
|
|||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("{}: {e}", path.display())),
|
||||
Err(e) => Err(i18n::t_args("file-error", Some(&file_error_args(path, &e)))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +415,10 @@ pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
|||
let path = dir.join(name);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(format!("{}: {e}", path.display()));
|
||||
return Err(i18n::t_args(
|
||||
"file-error",
|
||||
Some(&file_error_args(&path, &e)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -481,14 +502,18 @@ pub fn fetch(
|
|||
cancel: Some(&cancel),
|
||||
};
|
||||
let fetched = client::fetch_with(store, &me, opts).map_err(explain)?;
|
||||
let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored);
|
||||
if !fetched.rejected.is_empty() {
|
||||
note.push_str(&format!(", {} rejected", fetched.rejected.len()));
|
||||
}
|
||||
if fetched.cancelled {
|
||||
note.push_str(" (cancelled)");
|
||||
}
|
||||
Ok(note)
|
||||
let variant = match (!fetched.rejected.is_empty(), fetched.cancelled) {
|
||||
(true, true) => "rejected-cancelled",
|
||||
(true, false) => "rejected",
|
||||
(false, true) => "cancelled",
|
||||
(false, false) => "plain",
|
||||
};
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("variant", variant);
|
||||
args.set("total", fetched.total as i64);
|
||||
args.set("stored", fetched.stored as i64);
|
||||
args.set("rejected", fetched.rejected.len() as i64);
|
||||
Ok(i18n::t_args("fetch-result-note", Some(&args)))
|
||||
}
|
||||
|
||||
/// Fields fumi itself attaches to every message (sec 5.7, 5.8); a
|
||||
|
|
@ -506,14 +531,18 @@ pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
|
|||
return Ok(None);
|
||||
}
|
||||
let Some((key, value)) = text.split_once(':') else {
|
||||
return Err("a header is `Key: value` — the colon is missing".into());
|
||||
return Err(i18n::t("compose-header-colon-missing"));
|
||||
};
|
||||
let key = key.trim();
|
||||
if RESERVED_HEADERS
|
||||
.iter()
|
||||
.any(|reserved| key.eq_ignore_ascii_case(reserved))
|
||||
{
|
||||
return Err(format!("{key} is attached by the client and cannot be set"));
|
||||
return Err(i18n::t1(
|
||||
"compose-header-reserved-key",
|
||||
"key",
|
||||
key.to_string(),
|
||||
));
|
||||
}
|
||||
if key.is_empty()
|
||||
|| key.len() > 64
|
||||
|
|
@ -521,10 +550,10 @@ pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
|
|||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err("keys are 1-64 characters of letters, digits and dashes".into());
|
||||
return Err(i18n::t("compose-header-invalid-key"));
|
||||
}
|
||||
if value.contains('\n') || value.contains('\r') {
|
||||
return Err("a value must stay on one line".into());
|
||||
return Err(i18n::t("compose-header-value-multiline"));
|
||||
}
|
||||
Ok(Some((key.to_string(), value.trim().to_string())))
|
||||
}
|
||||
|
|
@ -536,7 +565,7 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> {
|
|||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for (key, _) in headers {
|
||||
if !seen.insert(key.to_ascii_lowercase()) {
|
||||
return Err(format!("{key} is set twice"));
|
||||
return Err(i18n::t1("compose-header-duplicate-key", "key", key.clone()));
|
||||
}
|
||||
}
|
||||
let budget: usize = headers
|
||||
|
|
@ -546,10 +575,10 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> {
|
|||
// The client adds Subject, In-Reply-To, a Reply-To URI and possibly an
|
||||
// Accept token on top of the user's rows; 512 bytes covers them all.
|
||||
if budget + 512 > FRONTMATTER_MAX {
|
||||
return Err("the headers exceed the frontmatter budget".into());
|
||||
return Err(i18n::t("compose-headers-too-large"));
|
||||
}
|
||||
if headers.len() + 4 > FRONTMATTER_KEYS {
|
||||
return Err("too many headers".into());
|
||||
return Err(i18n::t("compose-headers-too-many"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -592,20 +621,27 @@ pub fn send(
|
|||
no_pad: false,
|
||||
};
|
||||
let sent = client::send(store, &me, &draft, timeout).map_err(explain)?;
|
||||
let change = match sent.change {
|
||||
TrustChange::New { unverified: true } => {
|
||||
"; new contact learned from an unpinned server (unverified)"
|
||||
}
|
||||
TrustChange::New { unverified: false } => "; new contact learned (trust on first use)",
|
||||
TrustChange::Rotated => "; contact rotated to a new key (chain verified)",
|
||||
TrustChange::None => "",
|
||||
let trust = match sent.change {
|
||||
TrustChange::New { unverified: true } => Some("unverified"),
|
||||
TrustChange::New { unverified: false } => Some("tofu"),
|
||||
TrustChange::Rotated => Some("rotated"),
|
||||
TrustChange::None => None,
|
||||
};
|
||||
let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes);
|
||||
if sent.token_used {
|
||||
note.push_str(", accept token attached");
|
||||
}
|
||||
note.push_str(change);
|
||||
let variant = match (trust, sent.token_used) {
|
||||
(Some(trust), true) => format!("{trust}-token"),
|
||||
(Some(trust), false) => trust.to_string(),
|
||||
(None, true) => "token".to_string(),
|
||||
(None, false) => "plain".to_string(),
|
||||
};
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("variant", variant);
|
||||
args.set("address", addr.short());
|
||||
args.set("bytes", sent.bytes as i64);
|
||||
let mut note = i18n::t_args("send-result-note", Some(&args));
|
||||
if let Some(warning) = sent.warning {
|
||||
// Fumi's own warning text, appended as a second, independent
|
||||
// clause rather than folded into the catalogue — it isn't ours
|
||||
// to translate.
|
||||
note.push_str(&format!("; {warning}"));
|
||||
}
|
||||
Ok(note)
|
||||
|
|
@ -669,7 +705,7 @@ pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), Stri
|
|||
let addr = parse_address(uri)?;
|
||||
let key = addr
|
||||
.identity
|
||||
.ok_or("import needs a self-certifying smol:// address carrying a key")?;
|
||||
.ok_or_else(|| i18n::t("contacts-import-needs-key"))?;
|
||||
store
|
||||
.save_contact(&addr.short(), &key, true)
|
||||
.map_err(explain)?;
|
||||
|
|
@ -723,8 +759,13 @@ pub fn rotate(
|
|||
/// Writes the gsmol backup to the chosen path.
|
||||
pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result<String, String> {
|
||||
let text = export::export(store, master).map_err(explain)?;
|
||||
std::fs::write(path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(format!("backup written to {}", path.display()))
|
||||
std::fs::write(path, &text)
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(path, &e))))?;
|
||||
Ok(i18n::t1(
|
||||
"backup-written-note",
|
||||
"path",
|
||||
path.display().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
|
||||
|
|
@ -739,10 +780,10 @@ fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> {
|
|||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err("message ids are 64 hex characters".to_string());
|
||||
return Err(i18n::t("mail-message-id-wrong-length"));
|
||||
};
|
||||
raw.try_into()
|
||||
.map_err(|_| "message ids are 32 bytes".to_string())
|
||||
.map_err(|_| i18n::t("mail-message-id-wrong-bytes"))
|
||||
}
|
||||
|
||||
/// Lists one folder as rows, marking unread against the local read set.
|
||||
|
|
@ -796,7 +837,7 @@ pub fn open(
|
|||
.map_err(explain)?
|
||||
.into_iter()
|
||||
.find(|m| m.id == raw)
|
||||
.ok_or_else(|| "message not found".to_string())?;
|
||||
.ok_or_else(|| i18n::t("mail-message-not-found"))?;
|
||||
let described = client::describe(store, &me, &stored).map_err(explain)?;
|
||||
// The subject sits in the header, the reply address powers Reply and
|
||||
// the accept/block actions, and the Accept token is consumed by the
|
||||
|
|
@ -1064,6 +1105,23 @@ mod tests {
|
|||
Store::open_in_memory().expect("in-memory store")
|
||||
}
|
||||
|
||||
/// Fluent wraps every interpolated value in invisible bidi-isolation
|
||||
/// marks by default; disabled in `i18n::bundle()` because this app
|
||||
/// has one left-to-right locale and those marks would otherwise leak
|
||||
/// into copy/paste, the clipboard and plain string comparisons.
|
||||
#[test]
|
||||
fn interpolated_messages_carry_no_bidi_isolation_marks() {
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("variant", "plain");
|
||||
args.set("total", 5i64);
|
||||
args.set("stored", 5i64);
|
||||
args.set("rejected", 0i64);
|
||||
assert_eq!(
|
||||
i18n::t_args("fetch-result-note", Some(&args)),
|
||||
"5 fetched, 5 stored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_round_trips_through_base32_and_hex() {
|
||||
let m = master();
|
||||
|
|
|
|||
586
src/view.rs
586
src/view.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue