The daemon half of writing a conversation to disk

Groundwork for #114. The window renders and this writes, and the split is
not arbitrary: the HTML a message becomes is produced by the renderer's
format.ts - seven hundred lines of markdown, BBCode, spoilers, code blocks,
custom emoji, nick colours and IRC control codes - and an export is meant
to be what was on screen rather than a second opinion about it. Rewriting
that in Rust would be two formatters that agree until they do not, and the
one that is wrong would be the one nobody looks at for months.

So the window drives the loop and everything that is not formatting is
here: the folder, the media, the pacing, the progress and the ways to stop.

An export is a transfer rather than a mechanism of its own, because it is
the same thing to the person waiting for it - a long job with a size, a
rate and a way to call it off. That gets the downloads panel, the progress,
the persistence across a restart and the cancel path for free, and they
already agree with each other. `transfers` grows a `kind` column, defaulted
so every row written before it is the only thing it could have been, and
`DccState` grows `Paused` - reachable only for work that can be put down,
which a DCC socket cannot be and an export between pages can.

Decisions worth naming:

- `index.html` is written last, wrapping a body accumulated beside it. A
  folder with no index.html is an export that did not finish, which is a
  better thing to find than a truncated page that looks whole.
- The page carries its own styling inline. An export is something somebody
  keeps, mails on, or opens in five years; a folder whose page needs a
  stylesheet found beside it is one careless copy from being unreadable.
- Media is paced at 250ms and capped at 25MB. A conversation's worth of
  pictures is a burst at one host, and a host that decides moho is a
  scraper stops answering for the running client too - it is the same
  address. Anything not fetched keeps its original URL, so the page still
  reads rather than breaking.
- Names off the network never decide where a file lands: `safe_name` also
  handles the Windows reserved names, because an export is meant to survive
  being copied onto a memory stick.

Still to come: the header menu, the range dialog, the cog indicator and the
downloads row.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 23:26:04 -04:00
co-authored by Claude Opus 5
parent 7822d32115
commit 2e9bd0d7d3
6 changed files with 576 additions and 4 deletions
+9 -1
View File
@@ -731,7 +731,9 @@ pub async fn offer_file(state: &AppState, account_id: &str, nick: &str, path: &s
state: crate::runtime::DccState::Offered,
path: Some(path.to_string()),
error: None,
kind: crate::runtime::TransferKind::Dcc,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
offer: None,
started_at: now_seconds(),
};
@@ -1010,7 +1012,9 @@ pub async fn incoming(state: &AppState, account_id: &str, from: &str, buffer: &s
state: crate::runtime::DccState::Offered,
path: None,
error: None,
kind: crate::runtime::TransferKind::Dcc,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
offer: Some(offer),
started_at: now_seconds(),
};
@@ -1147,6 +1151,7 @@ pub fn announce(state: &AppState, t: &crate::runtime::DccTransfer) {
path: t.path.clone(),
error: t.error.clone(),
ts: t.started_at,
kind: t.kind.as_str().to_string(),
}) {
tracing::debug!("dcc: remembering a transfer: {e}");
}
@@ -1183,7 +1188,9 @@ pub fn restore_transfers(state: &AppState) {
state: if interrupted { crate::runtime::DccState::Failed } else { crate::runtime::DccState::from_str(&row.state) },
path: row.path,
error: if interrupted { Some("interrupted when moho was last closed".to_string()) } else { row.error },
kind: crate::runtime::TransferKind::from_str(&row.kind),
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
offer: None,
started_at: row.ts,
});
@@ -1206,6 +1213,7 @@ pub fn transfer_json(t: &crate::runtime::DccTransfer) -> serde_json::Value {
"received": t.received,
"rate": t.rate,
"state": t.state.as_str(),
"kind": t.kind.as_str(),
"path": t.path,
"error": t.error,
})
@@ -1232,7 +1240,7 @@ fn note(state: &AppState, account_id: &str, buffer: &str, kind: &str, text: &str
);
}
fn now_seconds() -> i64 {
pub fn now_seconds() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64
}
+428
View File
@@ -0,0 +1,428 @@
//! A conversation written to disk as the folder of HTML it looks like here.
//!
//! ## Who does what
//!
//! The window renders and this writes. That split is not arbitrary: the HTML a
//! message becomes is produced by `src/renderer/src/lib/format.ts`, seven
//! hundred-odd lines covering markdown, BBCode, spoilers, code blocks, custom
//! emoji, nick colours and IRC control codes, and an export is supposed to be
//! what was on screen rather than a second opinion about it. Rewriting that
//! here would be two formatters that agree until they do not, and the one that
//! is wrong would be the one nobody looks at until months later.
//!
//! So the window drives the loop - it asks for messages, renders them, and
//! hands the HTML over a page at a time - and everything that is not
//! formatting lives here: the folder, the media, the pacing, the progress and
//! the ways to stop.
//!
//! ## Why it is a transfer
//!
//! An export appears in the downloads panel beside DCC files, because it is
//! the same kind of thing to the person waiting for it: a long job with a
//! size, a rate, and a way to call it off. Reusing the transfer row means the
//! panel, the progress, the rate, the persistence across a restart and the
//! cancel path all already exist and already agree with each other. The `kind`
//! on the row is what lets the panel say "export" where it would otherwise say
//! a filename.
//!
//! ## What lands on disk
//!
//! A folder, not a file, because the media comes too. `index.html` is written
//! last, wrapping the body that has been accumulating beside it - so a folder
//! with no `index.html` in it is an export that did not finish, which is a
//! more useful thing to find than a truncated page that looks complete.
use crate::state::AppState;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
/// How long to wait between fetching one piece of media and the next.
///
/// A conversation's worth of pictures is a burst of requests at one host, and
/// a host that decides moho is a scraper is a host that stops answering for
/// everybody - including the running client, since this shares its address.
/// Slower than it could be, on purpose: an export is something somebody
/// started and is content to wait for.
const MEDIA_PACE: std::time::Duration = std::time::Duration::from_millis(250);
/// The most one picture may be. Past this it is linked rather than fetched -
/// an export is a readable copy of a conversation, not a mirror of every video
/// somebody ever posted in it.
const MEDIA_MAX_BYTES: u64 = 25 * 1024 * 1024;
fn http_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"), " (nobilis)"))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// Turns anything into something safe to be a directory or file name.
///
/// A conversation is named by the network, and `#channel`, a Matrix room with
/// a slash in it and a Windows reserved name are all things somebody can be
/// sitting in. Nothing off the network decides where a file lands.
pub fn safe_name(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
.collect();
let trimmed = cleaned.trim_matches(['-', '.']).to_string();
let trimmed = if trimmed.is_empty() { "conversation".to_string() } else { trimmed };
// Windows will not have these whatever the extension, and an export folder
// is meant to survive being copied onto a memory stick.
const RESERVED: [&str; 9] = ["CON", "PRN", "AUX", "NUL", "COM1", "COM2", "LPT1", "LPT2", "LPT3"];
let upper = trimmed.to_ascii_uppercase();
let safe = if RESERVED.contains(&upper.as_str()) { format!("{trimmed}-") } else { trimmed };
safe.chars().take(80).collect()
}
/// Where an export's pieces live while it is being written.
pub struct Paths {
pub root: PathBuf,
pub media: PathBuf,
pub body: PathBuf,
pub index: PathBuf,
}
pub fn paths(root: &Path) -> Paths {
Paths {
root: root.to_path_buf(),
media: root.join("media"),
// Accumulated separately from index.html so that an unfinished export
// has no index.html at all, rather than half a page that looks whole.
body: root.join(".body.html.part"),
index: root.join("index.html"),
}
}
/// Picks a folder inside `parent` that is not already taken.
///
/// A second export of the same conversation on the same day is an ordinary
/// thing to do, and silently writing into the first one would destroy it.
fn free_folder(parent: &Path, base: &str) -> PathBuf {
let first = parent.join(base);
if !first.exists() {
return first;
}
for n in 2..1000 {
let candidate = parent.join(format!("{base}-{n}"));
if !candidate.exists() {
return candidate;
}
}
parent.join(format!("{base}-{}", crate::model::next_message_id()))
}
/// Starts one, and puts it in the downloads panel.
///
/// `parent` is where the window says downloads go - resolved there because
/// that is where the platform's own answer lives, and where somebody may have
/// chosen differently.
pub fn begin(
state: &AppState,
account_id: &str,
buffer_id: &str,
title: &str,
parent: &str,
) -> Result<serde_json::Value> {
let parent = PathBuf::from(parent);
if !parent.is_dir() {
bail!("{} is not a folder", parent.display());
}
let stamp = chrono::Local::now().format("%Y-%m-%d").to_string();
let root = free_folder(&parent, &format!("{}-{stamp}", safe_name(title)));
let p = paths(&root);
std::fs::create_dir_all(&p.media).with_context(|| format!("making {}", p.media.display()))?;
std::fs::write(&p.body, "").with_context(|| format!("making {}", p.body.display()))?;
let id = format!("export-{}", crate::model::next_message_id());
state.runtime.push_dcc_transfer(crate::runtime::DccTransfer {
id: id.clone(),
account_id: account_id.to_string(),
outgoing: false,
from: title.to_string(),
file_name: root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
raw_name: buffer_id.to_string(),
// Unknown until the range has been walked. The panel reads a zero size
// as "no total yet" and shows what has been done instead of a
// percentage of nothing.
size: 0,
received: 0,
rate: 0,
state: crate::runtime::DccState::Receiving,
path: Some(root.to_string_lossy().into_owned()),
error: None,
kind: crate::runtime::TransferKind::Export,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
offer: None,
started_at: crate::backend::irc::dcc::now_seconds(),
});
if let Some(t) = state.runtime.dcc_transfer(&id) {
crate::backend::irc::dcc::announce(state, &t);
}
Ok(serde_json::json!({ "id": id, "path": root.to_string_lossy() }))
}
/// Whether the window should keep going, and why not if it should stop.
///
/// Read rather than enforced: the loop lives in the window, so this is how it
/// is told. A cancelled export answers "stop" once and then has nothing more
/// to say - the folder is left where it is, since a half-finished export is
/// sometimes what somebody wanted when they pressed cancel.
pub fn status(state: &AppState, id: &str) -> Result<serde_json::Value> {
let Some(t) = state.runtime.dcc_transfer(id) else { bail!("no such export") };
Ok(serde_json::json!({
"cancelled": t.cancel.load(Ordering::Relaxed),
"paused": t.paused.load(Ordering::Relaxed),
"done": matches!(t.state, crate::runtime::DccState::Done | crate::runtime::DccState::Failed),
}))
}
/// Takes one page of rendered conversation.
///
/// `done` and `total` are what the window knows about its own progress - it is
/// the one walking the range, so it is the one that can say. Appending rather
/// than holding it all in memory because a year of a busy channel is not
/// something to keep twice.
pub fn append(state: &AppState, id: &str, html: &str, done: u64, total: u64) -> Result<()> {
let Some(t) = state.runtime.dcc_transfer(id) else { bail!("no such export") };
if t.cancel.load(Ordering::Relaxed) {
bail!("export cancelled");
}
let p = paths(Path::new(t.path.as_deref().unwrap_or_default()));
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(&p.body).with_context(|| format!("writing {}", p.body.display()))?;
f.write_all(html.as_bytes()).context("writing a page of the export")?;
if let Some(t) = state.runtime.update_dcc(id, |t| {
t.received = done;
t.size = total;
}) {
crate::backend::irc::dcc::announce(state, &t);
}
Ok(())
}
/// Fetches one piece of media into the export's folder.
///
/// Answers with the path to write into the page - the local one where the
/// fetch worked, and the original URL where it did not. A picture that cannot
/// be had should leave a page that still reads, not a broken export.
pub async fn fetch_media(state: &AppState, id: &str, url: &str) -> Result<serde_json::Value> {
let Some(t) = state.runtime.dcc_transfer(id) else { bail!("no such export") };
if t.cancel.load(Ordering::Relaxed) {
bail!("export cancelled");
}
let p = paths(Path::new(t.path.as_deref().unwrap_or_default()));
// Named by what it is rather than by what it was called, so two files
// called image.png from two hosts do not become one.
let digest = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut h);
h.finish()
};
let ext = url
.rsplit('/')
.next()
.and_then(|tail| tail.split(['?', '#']).next())
.and_then(|name| name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()))
.filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or_else(|| "bin".to_string());
let name = format!("{digest:016x}.{ext}");
let dest = p.media.join(&name);
let relative = format!("media/{name}");
// Already fetched, because the same picture posted twice is one file.
if dest.exists() {
return Ok(serde_json::json!({ "path": relative, "cached": true }));
}
tokio::time::sleep(MEDIA_PACE).await;
let answer = http_client().get(url).send().await;
let bytes = match answer {
Ok(r) if r.status().is_success() => {
if r.content_length().is_some_and(|n| n > MEDIA_MAX_BYTES) {
tracing::debug!("export: {url} is larger than the cap, linking it instead");
return Ok(serde_json::json!({ "path": url, "skipped": "too large" }));
}
r.bytes().await.ok()
}
Ok(r) => {
tracing::debug!("export: {url} answered {}", r.status());
None
}
Err(e) => {
tracing::debug!("export: fetching {url}: {e}");
None
}
};
let Some(bytes) = bytes.filter(|b| (b.len() as u64) <= MEDIA_MAX_BYTES) else {
// The page keeps the original address, so it still works online even
// though the folder is no longer self-contained.
return Ok(serde_json::json!({ "path": url, "skipped": "could not be fetched" }));
};
std::fs::write(&dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
Ok(serde_json::json!({ "path": relative, "bytes": bytes.len() }))
}
/// Seals it: wraps the accumulated body in a page and writes `index.html`.
pub fn finish(state: &AppState, id: &str, title: &str, subtitle: &str) -> Result<serde_json::Value> {
let Some(t) = state.runtime.dcc_transfer(id) else { bail!("no such export") };
let p = paths(Path::new(t.path.as_deref().unwrap_or_default()));
let body = std::fs::read_to_string(&p.body).unwrap_or_default();
let page = page(title, subtitle, &body);
std::fs::write(&p.index, page).with_context(|| format!("writing {}", p.index.display()))?;
let _ = std::fs::remove_file(&p.body);
if let Some(t) = state.runtime.update_dcc(id, |t| {
t.state = crate::runtime::DccState::Done;
t.path = Some(p.index.to_string_lossy().into_owned());
}) {
crate::backend::irc::dcc::announce(state, &t);
}
Ok(serde_json::json!({ "path": p.index.to_string_lossy() }))
}
/// Gives up on one, with a reason somebody can act on.
pub fn fail(state: &AppState, id: &str, why: &str) {
if let Some(t) = state.runtime.update_dcc(id, |t| {
t.state = crate::runtime::DccState::Failed;
t.error = Some(why.to_string());
}) {
crate::backend::irc::dcc::announce(state, &t);
}
}
/// Puts one down or picks it back up.
pub fn set_paused(state: &AppState, id: &str, paused: bool) -> Result<()> {
let Some(t) = state.runtime.dcc_transfer(id) else { bail!("no such export") };
t.paused.store(paused, Ordering::Relaxed);
if let Some(t) = state.runtime.update_dcc(id, |t| {
t.state = if paused { crate::runtime::DccState::Paused } else { crate::runtime::DccState::Receiving };
}) {
crate::backend::irc::dcc::announce(state, &t);
}
Ok(())
}
/// The document the pages are poured into.
///
/// Deliberately one file with the styling inside it. An export is something
/// somebody keeps, mails to a lawyer, or opens in five years - a folder whose
/// page depends on a stylesheet that has to be found beside it is a folder one
/// careless copy away from being unreadable. The media is separate because it
/// has to be; nothing else is.
fn page(title: &str, subtitle: &str, body: &str) -> String {
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
color-scheme: light dark;
--bg: #ffffff; --fg: #1a1a1a; --dim: #6a6a6a; --line: #e3e3e3;
--quote: #f4f4f5; --code: #f0f0f1;
}}
@media (prefers-color-scheme: dark) {{
:root {{ --bg: #16171a; --fg: #e6e6e6; --dim: #9a9a9a; --line: #2c2e33;
--quote: #1e2024; --code: #1e2024; }}
}}
body {{ margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }}
header {{ padding: 20px 24px; border-bottom: 1px solid var(--line); }}
h1 {{ margin: 0 0 4px; font-size: 18px; }}
.sub {{ color: var(--dim); font-size: 13px; }}
main {{ padding: 16px 24px 48px; }}
.msg {{ display: flex; gap: 10px; padding: 3px 0; }}
.msg time {{ color: var(--dim); font-variant-numeric: tabular-nums;
flex: none; font-size: 12px; padding-top: 2px; }}
.msg .who {{ font-weight: 600; flex: none; }}
.msg .what {{ min-width: 0; overflow-wrap: anywhere; }}
.msg.system {{ color: var(--dim); font-style: italic; }}
blockquote {{ margin: 4px 0; padding: 4px 10px; background: var(--quote);
border-left: 3px solid var(--line); }}
pre, code {{ background: var(--code); border-radius: 4px; }}
pre {{ padding: 8px 10px; overflow-x: auto; }}
code {{ padding: 1px 4px; }}
img, video {{ max-width: min(100%, 480px); height: auto; border-radius: 6px; }}
a {{ color: inherit; }}
</style>
</head>
<body>
<header><h1>{title}</h1><div class="sub">{subtitle}</div></header>
<main>
{body}
</main>
</body>
</html>
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Nothing off the network decides where a file lands.
#[test]
fn a_conversations_name_is_made_safe_to_be_a_folder() {
assert_eq!(safe_name("#general"), "general");
assert_eq!(safe_name("!abc:matrix.org"), "abc-matrix.org");
assert_eq!(safe_name("../../etc/passwd"), "etc-passwd");
assert_eq!(safe_name("a/b\\c"), "a-b-c");
}
/// A name that is nothing but punctuation still has to be something.
#[test]
fn a_name_that_survives_as_nothing_gets_a_name() {
assert_eq!(safe_name("###"), "conversation");
assert_eq!(safe_name(""), "conversation");
assert_eq!(safe_name("..."), "conversation");
}
/// Windows will not have these, and an export is meant to survive being
/// copied onto a memory stick.
#[test]
fn a_reserved_windows_name_is_not_used_bare() {
assert_eq!(safe_name("CON"), "CON-");
assert_eq!(safe_name("nul"), "nul-");
assert_eq!(safe_name("console"), "console");
}
#[test]
fn a_very_long_name_is_cut_to_something_a_filesystem_will_take() {
assert_eq!(safe_name(&"a".repeat(300)).len(), 80);
}
/// An unfinished export has no index.html, so what is found on disk says
/// whether it finished.
#[test]
fn the_page_is_written_somewhere_the_body_is_not() {
let p = paths(Path::new("/tmp/x"));
assert_ne!(p.body, p.index);
assert!(p.index.ends_with("index.html"));
assert!(p.media.ends_with("media"));
}
#[test]
fn the_page_carries_its_own_styling() {
let html = page("#general", "1 March - 2 April", "<div class=\"msg\"></div>");
assert!(html.contains("<style>"));
assert!(html.contains("#general"));
assert!(html.contains("1 March - 2 April"));
assert!(html.contains("class=\"msg\""));
// Nothing to fetch for the page itself to render.
assert!(!html.contains("<link"));
}
}
+1
View File
@@ -3,6 +3,7 @@ mod audio;
mod backend;
mod commands;
mod events;
mod export;
mod highlights;
mod ignores;
mod ipc;
+89
View File
@@ -2018,6 +2018,95 @@ pub async fn dispatch(
}
}
// Writing a conversation to disk.
//
// Six calls rather than one, because the window drives the loop: the
// HTML a message becomes is made by the renderer's own formatter, and
// an export is meant to be what was on screen rather than a second
// opinion about it (see export.rs). So the window walks the range and
// renders, and everything that is not formatting is here.
"beginExport" => {
let (Some(account_id), Some(buffer_id), Some(title), Some(into)) = (
p_str_opt(params, "accountId"),
p_str_opt(params, "bufferId"),
p_str_opt(params, "title"),
p_str_opt(params, "into"),
) else {
return (None, Some("beginExport requires \"accountId\", \"bufferId\", \"title\" and \"into\"".to_string()));
};
match crate::export::begin(state, account_id, buffer_id, title, into) {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Whether to keep going. The loop is in the window, so this is how
// cancel and pause reach it.
"exportStatus" => match p_str_opt(params, "id") {
Some(id) => match crate::export::status(state, id) {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(format!("{e:#}"))),
},
None => (None, Some("exportStatus requires \"id\"".to_string())),
},
"appendExport" => {
let (Some(id), Some(html)) = (p_str_opt(params, "id"), p_str_opt(params, "html")) else {
return (None, Some("appendExport requires \"id\" and \"html\"".to_string()));
};
let done = params.get("done").and_then(Value::as_u64).unwrap_or(0);
let total = params.get("total").and_then(Value::as_u64).unwrap_or(0);
match crate::export::append(state, id, html, done, total) {
Ok(()) => (Some(Value::Bool(true)), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Fetching a picture into the folder, paced so a conversation's worth
// of them does not read as a scrape.
"fetchExportMedia" => {
let (Some(id), Some(url)) = (p_str_opt(params, "id"), p_str_opt(params, "url")) else {
return (None, Some("fetchExportMedia requires \"id\" and \"url\"".to_string()));
};
match crate::export::fetch_media(state, id, url).await {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"finishExport" => {
let (Some(id), Some(title)) = (p_str_opt(params, "id"), p_str_opt(params, "title")) else {
return (None, Some("finishExport requires \"id\" and \"title\"".to_string()));
};
match crate::export::finish(state, id, title, p_str(params, "subtitle", "")) {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Said by the window when its own half failed, so the row says why
// rather than sitting at whatever it had reached.
"failExport" => {
let Some(id) = p_str_opt(params, "id") else {
return (None, Some("failExport requires \"id\"".to_string()));
};
crate::export::fail(state, id, p_str(params, "reason", "the export could not be finished"));
(Some(Value::Bool(true)), None)
}
// Only an export can be put down and picked back up - a DCC socket
// cannot be, which is why this is not on cancelTransfer's path.
"pauseTransfer" => {
let Some(id) = p_str_opt(params, "id") else {
return (None, Some("pauseTransfer requires \"id\"".to_string()));
};
let paused = params.get("paused").and_then(Value::as_bool).unwrap_or(true);
match crate::export::set_paused(state, id, paused) {
Ok(()) => (Some(Value::Bool(true)), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"acceptTransfer" => {
let Some(id) = p_str_opt(params, "id") else {
return (None, Some("acceptTransfer requires \"id\"".to_string()));
+39
View File
@@ -84,8 +84,16 @@ pub struct DccTransfer {
pub path: Option<String>,
/// Why it failed, or why it was refused.
pub error: Option<String>,
/// What this row is. A DCC file and a conversation export are different
/// enough to describe differently and alike enough to belong in one list:
/// both are a long job with a size, a rate and a way to stop it, and what
/// somebody wants is "what is moving", not two panels of it.
pub kind: TransferKind,
/// Raised to stop a transfer that is already running.
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Raised to hold a transfer where the work can be put down and picked
/// up again. A DCC socket cannot; an export between pages can.
pub paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// The offer itself, kept so accepting does not have to read anything
/// off the network a second time. Dropped once it is settled.
pub offer: Option<crate::backend::irc::dcc::DccSend>,
@@ -95,12 +103,42 @@ pub struct DccTransfer {
pub started_at: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TransferKind {
/// A file over DCC - see backend/irc/dcc.rs.
#[default]
Dcc,
/// A conversation written to disk - see export.rs.
Export,
}
impl TransferKind {
pub fn as_str(self) -> &'static str {
match self {
TransferKind::Dcc => "dcc",
TransferKind::Export => "export",
}
}
/// Anything unrecognised is a DCC file, which is what every row written
/// before this field existed is.
pub fn from_str(s: &str) -> Self {
match s {
"export" => TransferKind::Export,
_ => TransferKind::Dcc,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DccState {
/// Waiting on an answer - or, when sending, waiting for them to accept.
Offered,
Receiving,
Sending,
/// Put down, and able to be picked up again. Only reachable for work
/// that can be resumed - see `TransferKind::Export`.
Paused,
Done,
Declined,
Failed,
@@ -125,6 +163,7 @@ impl DccState {
DccState::Offered => "offered",
DccState::Receiving => "receiving",
DccState::Sending => "sending",
DccState::Paused => "paused",
DccState::Done => "done",
DccState::Declined => "declined",
DccState::Failed => "failed",
+10 -3
View File
@@ -30,6 +30,9 @@ pub struct TransferRow {
pub path: Option<String>,
pub error: Option<String>,
pub ts: i64,
/// "dcc" or "export". Added after the table existed, so every row written
/// before it defaults to the only thing it could have been.
pub kind: String,
}
impl Store {
@@ -94,6 +97,7 @@ impl Store {
// Defensive no-ops for a scrollback.db predating these columns,
// same as store.c's post-hoc ALTER TABLE. Ignore "duplicate column".
for stmt in [
"ALTER TABLE transfers ADD COLUMN kind TEXT NOT NULL DEFAULT 'dcc'",
"ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'",
"ALTER TABLE messages ADD COLUMN reply_to_id TEXT",
"ALTER TABLE messages ADD COLUMN reply_to_from TEXT",
@@ -704,8 +708,8 @@ impl Store {
pub fn record_transfer(&self, t: &TransferRow) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO transfers (id, account_id, outgoing, peer, file_name, raw_name, size, received, state, path, error, ts)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
"INSERT INTO transfers (id, account_id, outgoing, peer, file_name, raw_name, size, received, state, path, error, ts, kind)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(id) DO UPDATE SET received = ?8, state = ?9, path = ?10, error = ?11",
params![
t.id,
@@ -720,6 +724,7 @@ impl Store {
t.path,
t.error,
t.ts,
t.kind,
],
)?;
Ok(())
@@ -755,7 +760,7 @@ impl Store {
pub fn recent_transfers(&self, limit: i64) -> Result<Vec<TransferRow>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, account_id, outgoing, peer, file_name, raw_name, size, received, state, path, error, ts
"SELECT id, account_id, outgoing, peer, file_name, raw_name, size, received, state, path, error, ts, kind
FROM transfers ORDER BY ts DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit], |row| {
@@ -772,6 +777,7 @@ impl Store {
path: row.get(9)?,
error: row.get(10)?,
ts: row.get(11)?,
kind: row.get(12)?,
})
})?;
rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
@@ -1036,6 +1042,7 @@ mod tests {
path: None,
error: None,
ts: 42,
kind: "dcc".to_string(),
};
st.record_transfer(&row).unwrap();
row.received = 100;