Add PDF archive store, raw-body IPC and export file helpers
The searchable PDF can be archived content-addressed under the local data dir with its sha256 and layout fingerprint; a different PDF for an archived invoice is refused and reads verify the hash. Export writes go through a raw-body command that remembers the last folder; reveal-in-folder and open fall back to xdg-open / explorer. Adds a Windows-safe export file name builder. Migration M4 adds the archive columns and last export dir.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
//! Helpers for commands that take their payload as a raw IPC body (no base64).
|
||||
//!
|
||||
//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as
|
||||
//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible
|
||||
//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file
|
||||
//! paths is percent-encoded by the caller.
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::collections::HashMap;
|
||||
use tauri::ipc::{InvokeBody, Request};
|
||||
|
||||
/// Lower-cased header name to value; values that are not valid UTF-8 are dropped.
|
||||
pub type Headers = HashMap<String, String>;
|
||||
|
||||
pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> {
|
||||
match request.body() {
|
||||
InvokeBody::Raw(bytes) => Ok(bytes),
|
||||
InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header_map(request: &Request<'_>) -> Headers {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
let value = value.to_str().ok()?;
|
||||
Some((name.as_str().to_ascii_lowercase(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> {
|
||||
headers
|
||||
.get(name)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| format!("Missing {name} header"))
|
||||
}
|
||||
|
||||
pub fn decode_header_path(value: &str) -> Result<String, String> {
|
||||
percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map(|s| s.into_owned())
|
||||
.map_err(|_| "The path header is not valid UTF-8".to_string())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a
|
||||
/// half-written file under the final name.
|
||||
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
let mut tmp_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| "The path has no file name".to_string())?
|
||||
.to_os_string();
|
||||
tmp_name.push(".tmp");
|
||||
let tmp = path.with_file_name(tmp_name);
|
||||
let result = (|| {
|
||||
let mut file = std::fs::File::create(&tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, path)
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("Could not write {}: {e}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_paths() {
|
||||
assert_eq!(
|
||||
decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(),
|
||||
"/tmp/a b/\u{9ac}.pdf"
|
||||
);
|
||||
assert!(decode_header_path("%FF").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_and_leaves_no_temp_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let target = dir.path().join("out.pdf");
|
||||
write_atomic(&target, b"one").unwrap();
|
||||
write_atomic(&target, b"two").unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"two");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user