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:
Generated
+2
@@ -4236,10 +4236,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"percent-encoding",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
percent-encoding = "2"
|
||||||
|
|
||||||
# Desktop-only plugins (not available on mobile targets).
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! Content-addressed archive of the searchable PDF produced when an invoice is issued.
|
||||||
|
//! Files live at `<local data dir>/archive/<sha256>.pdf`; the invoice row keeps the hash.
|
||||||
|
use super::raw::{header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::ipc::{Request, Response};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
const MAX_FINGERPRINT_LEN: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArchiveStatus {
|
||||||
|
pub archived: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archived_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
||||||
|
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
||||||
|
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||||
|
if !valid {
|
||||||
|
return Err("The stored archive hash is malformed".to_string());
|
||||||
|
}
|
||||||
|
Ok(local_dir.join("archive").join(format!("{sha256}.pdf")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_invoice_id(headers: &Headers) -> Result<i64, String> {
|
||||||
|
required_header(headers, "x-invoice-id")?
|
||||||
|
.trim()
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| "x-invoice-id must be a number".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_pdf_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
local_dir: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let id = parse_invoice_id(headers)?;
|
||||||
|
let fingerprint = required_header(headers, "x-fingerprint")?.trim();
|
||||||
|
if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN {
|
||||||
|
return Err("x-fingerprint must be 1-256 characters".to_string());
|
||||||
|
}
|
||||||
|
if !bytes.starts_with(b"%PDF-") {
|
||||||
|
return Err("The data is not a PDF".to_string());
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_ARCHIVE_BYTES {
|
||||||
|
return Err("The PDF is larger than the 64 MB archive limit".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let row: Option<(String, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
if status != "issued" && status != "cancelled" {
|
||||||
|
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sha = sha256_hex(bytes);
|
||||||
|
if let Some(existing) = &existing {
|
||||||
|
if *existing != sha {
|
||||||
|
return Err(
|
||||||
|
"This invoice already has an archived original; the issued PDF cannot be replaced"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||||
|
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
||||||
|
// Same content, same name; a matching length means the file is already in place.
|
||||||
|
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
||||||
|
if !present {
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3
|
||||||
|
WHERE id = ?4",
|
||||||
|
params![sha, fingerprint, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
||||||
|
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
"The archived PDF file is missing".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Could not read the archived PDF: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if sha256_hex(&bytes) != sha {
|
||||||
|
return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string());
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||||
|
let row: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
row.ok_or_else(|| "Invoice not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus, String> {
|
||||||
|
let row: Option<(Option<String>, Option<String>, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result<Response, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_status_impl(&conn, invoice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn conn_with_invoice(status: &str) -> Connection {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at)
|
||||||
|
VALUES ('INV/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')",
|
||||||
|
params![status],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers(id: &str) -> Headers {
|
||||||
|
Headers::from([
|
||||||
|
("x-invoice-id".to_string(), id.to_string()),
|
||||||
|
("x-fingerprint".to_string(), "fp-1".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF: &[u8] = b"%PDF-1.7\nbody";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archives_once_and_is_idempotent() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(sha.len(), 64);
|
||||||
|
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
||||||
|
|
||||||
|
let status = archive_status_impl(&conn, 1).unwrap();
|
||||||
|
assert!(status.archived);
|
||||||
|
assert_eq!(status.sha256.as_deref(), Some(sha.as_str()));
|
||||||
|
assert_eq!(status.fingerprint.as_deref(), Some("fp-1"));
|
||||||
|
let first_at = status.archived_at.clone().unwrap();
|
||||||
|
|
||||||
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(again, sha);
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_invoices_can_be_archived() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("cancelled");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_bytes_for_an_archived_invoice_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||||
|
assert!(err.contains("already has an archived original"), "{err}");
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
||||||
|
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not a PDF"));
|
||||||
|
let mut big = b"%PDF-".to_vec();
|
||||||
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("64 MB"));
|
||||||
|
assert!(!archive_status_impl(&conn, 1).unwrap().archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drafts_unknown_invoices_and_bad_headers_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("draft");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Only an issued invoice"));
|
||||||
|
assert_eq!(
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(),
|
||||||
|
"Invoice not found"
|
||||||
|
);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err());
|
||||||
|
let mut no_fp = headers("1");
|
||||||
|
no_fp.remove("x-fingerprint");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err());
|
||||||
|
assert!(!dir.path().join("archive").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_archive_detects_corruption_and_missing_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||||
|
|
||||||
|
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
||||||
|
|
||||||
|
std::fs::remove_file(&file).unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
||||||
|
// Archiving the same bytes again restores the file.
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = conn_with_invoice("issued");
|
||||||
|
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,22 +133,6 @@ pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String>
|
|||||||
remove_asset_file(&state.data_dir, &path)
|
remove_asset_file(&state.data_dir, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
|
|
||||||
if path.trim().is_empty() {
|
|
||||||
return Err("No file path given".into());
|
|
||||||
}
|
|
||||||
let bytes = STANDARD
|
|
||||||
.decode(data_base64.as_bytes())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
if let Some(parent) = Path::new(&path).parent() {
|
|
||||||
// A missing parent is created; if that fails the write below reports the real error.
|
|
||||||
std::fs::create_dir_all(parent).ok();
|
|
||||||
}
|
|
||||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -248,9 +232,4 @@ mod tests {
|
|||||||
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
||||||
assert!(dir.path().join("voiced.db").exists());
|
assert!(dir.path().join("voiced.db").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_binary_file_rejects_empty_path() {
|
|
||||||
assert!(save_binary_file(" ".into(), "AAAA".into()).is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! User-visible files: exports written to a path from the save dialog, and the
|
||||||
|
//! reveal/open helpers used afterwards.
|
||||||
|
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use tauri::ipc::Request;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
pub fn write_export_file_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let raw = decode_header_path(required_header(headers, "x-path")?)?;
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(&raw);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("The export path must be absolute".to_string());
|
||||||
|
}
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("Nothing to write: the file is empty".to_string());
|
||||||
|
}
|
||||||
|
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1",
|
||||||
|
params![parent.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
||||||
|
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_existing(path: &str) -> Result<PathBuf, String> {
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("{} does not exist", path.display()));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last resort when the opener plugin cannot reach a file manager (a minimal Linux
|
||||||
|
/// install has no D-Bus file-manager service or portal). Arguments are passed
|
||||||
|
/// individually; nothing goes through a shell.
|
||||||
|
fn reveal_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
cmd.arg(format!("/select,{}", path.display()));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
cmd.arg("-R").arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(folder);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||||
|
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||||
|
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
||||||
|
// (explorer.exe, for one, exits non-zero even on success).
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
write_export_file_impl(&conn, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_last_export_dir(state: State<AppState>) -> Result<String, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
get_last_export_dir_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().reveal_item_in_dir(&path).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(reveal_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(open_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn headers(path: &str) -> Headers {
|
||||||
|
Headers::from([("x-path".to_string(), path.to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(path: &Path) -> String {
|
||||||
|
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_the_file_and_remembers_the_folder() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
|
||||||
|
// Non-ASCII, spaces and a folder that does not exist yet.
|
||||||
|
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||||
|
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(PathBuf::from(&written), target);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||||
|
assert_eq!(
|
||||||
|
get_last_export_dir_impl(&conn).unwrap(),
|
||||||
|
target.parent().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Overwrites in place.
|
||||||
|
write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_relative_and_missing_paths() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_empty_body_without_touching_the_folder_setting() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let target = dir.path().join("a.pdf");
|
||||||
|
assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err());
|
||||||
|
assert!(!target.exists());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reveal_and_open_refuse_missing_paths() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(require_existing("").is_err());
|
||||||
|
let missing = dir.path().join("nope.pdf");
|
||||||
|
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
|
||||||
|
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn linux_fallback_opens_the_parent_folder_with_separate_args() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("a b; rm -rf.pdf");
|
||||||
|
std::fs::write(&file, b"x").unwrap();
|
||||||
|
let cmd = reveal_fallback_command(&file);
|
||||||
|
assert_eq!(cmd.get_program(), "xdg-open");
|
||||||
|
let args: Vec<_> = cmd.get_args().collect();
|
||||||
|
assert_eq!(args, [dir.path().as_os_str()]);
|
||||||
|
let open = open_fallback_command(&file);
|
||||||
|
assert_eq!(open.get_args().collect::<Vec<_>>(), [file.as_os_str()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien
|
|||||||
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
||||||
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
||||||
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, '')";
|
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at";
|
||||||
|
|
||||||
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||||
Ok(Invoice {
|
Ok(Invoice {
|
||||||
@@ -56,6 +56,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
cancel_reason: row.get(32)?,
|
cancel_reason: row.get(32)?,
|
||||||
archived_pdf_sha256: row.get(33)?,
|
archived_pdf_sha256: row.get(33)?,
|
||||||
render_prefs: row.get(34)?,
|
render_prefs: row.get(34)?,
|
||||||
|
archived_fingerprint: row.get(35)?,
|
||||||
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod files;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use tauri::State;
|
|||||||
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||||
signatory_designation, render_prefs";
|
signatory_designation, render_prefs, last_export_dir";
|
||||||
|
|
||||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
@@ -31,6 +31,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
signatory_name: row.get(18)?,
|
signatory_name: row.get(18)?,
|
||||||
signatory_designation: row.get(19)?,
|
signatory_designation: row.get(19)?,
|
||||||
render_prefs: row.get(20)?,
|
render_prefs: row.get(20)?,
|
||||||
|
last_export_dir: row.get(21)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 3;
|
const LATEST_VERSION: i64 = 4;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -174,8 +174,15 @@ const M3: &str = r#"
|
|||||||
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
|
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 4: the archived PDF's layout fingerprint and time, and the last export folder.
|
||||||
|
const M4: &str = r#"
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_fingerprint TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_at TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
fn migrations() -> Migrations<'static> {
|
fn migrations() -> Migrations<'static> {
|
||||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3)])
|
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
@@ -541,6 +548,27 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert!(backups_in(&backups).is_empty());
|
assert!(backups_in(&backups).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m4_adds_archive_and_export_dir_columns() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let dir: String = conn
|
||||||
|
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dir, "");
|
||||||
|
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||||
|
conn.prepare("SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices")
|
||||||
|
.unwrap();
|
||||||
|
let notnull: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT SUM(\"notnull\") FROM pragma_table_info('invoices')
|
||||||
|
WHERE name IN ('archived_fingerprint', 'archived_at')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(notnull, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
|
|||||||
+22
-1
@@ -12,6 +12,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
|
/// Machine-local, non-roaming data (the PDF archive). On Windows this is
|
||||||
|
/// %LOCALAPPDATA%, unlike `data_dir`, which is the roaming location.
|
||||||
|
pub local_data_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a native error dialog. Release builds have no console on Windows, so
|
/// Show a native error dialog. Release builds have no console on Windows, so
|
||||||
@@ -40,6 +43,17 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
let local_data_dir = match app.path().app_local_data_dir() {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
||||||
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -54,6 +68,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
|
local_data_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +128,13 @@ pub fn run() {
|
|||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
commands::assets::remove_asset,
|
commands::assets::remove_asset,
|
||||||
commands::assets::save_binary_file,
|
commands::archive::archive_pdf,
|
||||||
|
commands::archive::read_archive,
|
||||||
|
commands::archive::archive_status,
|
||||||
|
commands::files::write_export_file,
|
||||||
|
commands::files::get_last_export_dir,
|
||||||
|
commands::files::reveal_in_folder,
|
||||||
|
commands::files::open_file,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!());
|
.run(tauri::generate_context!());
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ pub struct Settings {
|
|||||||
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
|
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub render_prefs: String,
|
pub render_prefs: String,
|
||||||
|
/// Folder of the last export; owned by the backend, so saving settings never changes it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_export_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -199,6 +202,8 @@ pub struct Invoice {
|
|||||||
pub archived_pdf_sha256: Option<String>,
|
pub archived_pdf_sha256: Option<String>,
|
||||||
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
|
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
|
||||||
pub render_prefs: String,
|
pub render_prefs: String,
|
||||||
|
pub archived_fingerprint: Option<String>,
|
||||||
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -10,6 +10,13 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
export interface ArchiveStatus {
|
||||||
|
archived: boolean;
|
||||||
|
sha256?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
archivedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getSettings: () => invoke<Settings>("get_settings"),
|
getSettings: () => invoke<Settings>("get_settings"),
|
||||||
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
||||||
@@ -47,6 +54,19 @@ export const api = {
|
|||||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
||||||
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
||||||
saveBinaryFile: (path: string, dataBase64: string) =>
|
|
||||||
invoke<void>("save_binary_file", { path, dataBase64 }),
|
// Raw-body commands: the payload is the bytes themselves (no base64); metadata goes in headers.
|
||||||
|
// Header values must be visible ASCII, hence the encodeURIComponent on paths.
|
||||||
|
archivePdf: (invoiceId: number, bytes: Uint8Array, fingerprint: string) =>
|
||||||
|
invoke<string>("archive_pdf", bytes, {
|
||||||
|
headers: { "x-invoice-id": String(invoiceId), "x-fingerprint": fingerprint },
|
||||||
|
}),
|
||||||
|
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||||
|
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||||
|
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||||
|
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||||
|
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||||
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportFileName, type ExportFileNameInput } from "./filename";
|
||||||
|
|
||||||
|
const base: ExportFileNameInput = {
|
||||||
|
number: "INV-2026-001",
|
||||||
|
client: "Acme Ltd",
|
||||||
|
date: "2026-04-01",
|
||||||
|
mode: "searchable",
|
||||||
|
};
|
||||||
|
const name = (over: Partial<ExportFileNameInput> = {}) => exportFileName({ ...base, ...over });
|
||||||
|
|
||||||
|
describe("exportFileName", () => {
|
||||||
|
it("uses number_client_date by default", () => {
|
||||||
|
expect(name()).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a slash in the invoice number into a dash", () => {
|
||||||
|
expect(name({ number: "INV/2026-001" })).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat for the flattened export", () => {
|
||||||
|
expect(name({ mode: "flattened" })).toBe("INV-2026-001_Acme Ltd_2026-04-01-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a custom pattern", () => {
|
||||||
|
expect(name({ pattern: "{date} {client} {number}" })).toBe("2026-04-01 Acme Ltd INV-2026-001.pdf");
|
||||||
|
expect(name({ pattern: "{number}" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default pattern for a blank pattern", () => {
|
||||||
|
expect(name({ pattern: " " })).toBe(name());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces every Windows-forbidden character", () => {
|
||||||
|
expect(name({ client: 'A<B>C:D"E/F\\G|H?I*J' })).toBe("INV-2026-001_A-B-C-D-E-F-G-H-I-J_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces control characters", () => {
|
||||||
|
expect(name({ client: "A\u0000B\tC\nD\u007fE" })).toBe("INV-2026-001_A-B-C-D-E_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses runs of separators and spaces", () => {
|
||||||
|
expect(name({ client: "A///B C___D" })).toBe("INV-2026-001_A-B C_D_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims dots and spaces from the ends", () => {
|
||||||
|
expect(name({ pattern: " {client}. . " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Acme Ltd. " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "...hidden" })).toBe("hidden.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the separators left behind by empty parts", () => {
|
||||||
|
expect(name({ client: "" })).toBe("INV-2026-001_2026-04-01.pdf");
|
||||||
|
expect(name({ client: "", date: "" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes reserved device names", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON" })).toBe("_CON.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "nul" })).toBe("_nul.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Aux" })).toBe("_Aux.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "PRN" })).toBe("_PRN.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes COM1-9 and LPT1-9 but not COM10 or lookalikes", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "COM1" })).toBe("_COM1.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "com9" })).toBe("_com9.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "LPT3" })).toBe("_LPT3.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "COM10" })).toBe("COM10.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "CONSOLE" })).toBe("CONSOLE.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a reserved name that has an extension-like tail", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON.txt" })).toBe("_CON.txt.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Indic client names intact", () => {
|
||||||
|
expect(name({ client: "சென்னை ஸ்டூடியோ" })).toBe(
|
||||||
|
"INV-2026-001_சென்னை ஸ்டூடியோ_2026-04-01.pdf",
|
||||||
|
);
|
||||||
|
expect(name({ client: "ਭਾਰਤ/ਕੰਪਨੀ" })).toContain("ਭਾਰਤ-ਕੰਪਨੀ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises Unicode to NFC", () => {
|
||||||
|
const decomposed = "Café";
|
||||||
|
expect(name({ pattern: "{client}", client: decomposed })).toBe("Café.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the stem at 120 characters and cuts the client first", () => {
|
||||||
|
const out = name({ client: "x".repeat(300) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect(out.startsWith("INV-2026-001_x")).toBe(true);
|
||||||
|
expect(out.endsWith("_2026-04-01.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the number whole even when it alone is long", () => {
|
||||||
|
const number = "N".repeat(110);
|
||||||
|
const out = name({ number, client: "y".repeat(200) });
|
||||||
|
expect(out.startsWith(number)).toBe(true);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-caps an absurdly long number", () => {
|
||||||
|
const out = name({ number: "9".repeat(500) });
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not split a surrogate pair when truncating", () => {
|
||||||
|
const out = name({ pattern: "{client}", client: "\u{1F600}".repeat(100) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect([...stem].every((ch) => ch === "\u{1F600}")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat after truncation", () => {
|
||||||
|
const out = name({ client: "z".repeat(300), mode: "flattened" });
|
||||||
|
expect(out.endsWith("_2026-04-01-flat.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a non-empty fallback", () => {
|
||||||
|
expect(name({ number: "", client: "", date: "" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ pattern: "...", number: "x" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "///", client: "***", date: "|" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "", client: "", date: "", mode: "flattened" })).toBe("invoice-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the result never ends in a dot or space", () => {
|
||||||
|
const out = name({ client: "Trailing dot.", date: "" });
|
||||||
|
expect(out).not.toMatch(/[. ]\.pdf$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type ExportMode = "searchable" | "flattened";
|
||||||
|
|
||||||
|
export interface ExportFileNameInput {
|
||||||
|
/** Tokens: {number}, {client}, {date}. */
|
||||||
|
pattern?: string;
|
||||||
|
number: string;
|
||||||
|
client: string;
|
||||||
|
date: string;
|
||||||
|
mode: ExportMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PATTERN = "{number}_{client}_{date}";
|
||||||
|
const MAX_STEM = 120;
|
||||||
|
const FALLBACK = "invoice";
|
||||||
|
|
||||||
|
// Characters Windows forbids in file names, plus control characters.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
|
||||||
|
// The device name matters even with an extension ("CON.txt" is still the console).
|
||||||
|
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
||||||
|
const EDGE_JUNK = /^[.\s_-]+|[.\s_-]+$/g;
|
||||||
|
|
||||||
|
function collapse(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(FORBIDDEN, "-")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/_{2,}/g, "_")
|
||||||
|
.replace(/ ?([-_]) ?/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPart(value: string): string {
|
||||||
|
return collapse(value.normalize("NFC")).replace(EDGE_JUNK, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cut to at most `max` UTF-16 units without splitting a surrogate pair. */
|
||||||
|
function truncate(value: string, max: number): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of value) {
|
||||||
|
if (out.length + ch.length > max) break;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(pattern: string, parts: { number: string; client: string; date: string }): string {
|
||||||
|
return pattern.replace(/\{(number|client|date)\}/g, (_, key: keyof typeof parts) => parts[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file name that is valid on Windows, macOS and Linux for an exported invoice PDF. */
|
||||||
|
export function exportFileName(input: ExportFileNameInput): string {
|
||||||
|
const pattern = input.pattern?.trim() ? input.pattern : DEFAULT_PATTERN;
|
||||||
|
const parts = {
|
||||||
|
number: cleanPart(input.number),
|
||||||
|
client: cleanPart(input.client),
|
||||||
|
date: cleanPart(input.date),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stem = fill(pattern, parts);
|
||||||
|
if (stem.length > MAX_STEM) {
|
||||||
|
// The invoice number is what identifies the file, so the client name gives way first.
|
||||||
|
const overflow = stem.length - MAX_STEM;
|
||||||
|
const client = truncate(parts.client, Math.max(0, parts.client.length - overflow));
|
||||||
|
stem = fill(pattern, { ...parts, client });
|
||||||
|
}
|
||||||
|
stem = cleanPart(truncate(cleanPart(stem), MAX_STEM));
|
||||||
|
if (!stem) stem = FALLBACK;
|
||||||
|
if (RESERVED.test(stem.split(".")[0])) stem = `_${stem}`;
|
||||||
|
|
||||||
|
return `${stem}${input.mode === "flattened" ? "-flat" : ""}.pdf`;
|
||||||
|
}
|
||||||
+1
-8
@@ -10,13 +10,6 @@ import { imageSizeFromDataUri } from "./imageSize";
|
|||||||
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
import type { BankAccount, Invoice, Settings } from "./types";
|
||||||
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
|
||||||
let bin = "";
|
|
||||||
const step = 0x8000;
|
|
||||||
for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step));
|
|
||||||
return btoa(bin);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
||||||
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
||||||
|
|
||||||
@@ -82,7 +75,7 @@ export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise
|
|||||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
||||||
});
|
});
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
await api.saveBinaryFile(path, bytesToBase64(bytes));
|
await api.writeExportFile(path, bytes);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface Settings {
|
|||||||
signatoryDesignation: string;
|
signatoryDesignation: string;
|
||||||
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
|
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
|
||||||
renderPrefs: string;
|
renderPrefs: string;
|
||||||
|
/** Folder of the last export; kept by the backend (saving settings does not change it). */
|
||||||
|
lastExportDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankAccount {
|
export interface BankAccount {
|
||||||
@@ -136,6 +138,9 @@ export interface Invoice {
|
|||||||
archivedPdfSha256: string | null;
|
archivedPdfSha256: string | null;
|
||||||
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
|
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
|
||||||
renderPrefs: string;
|
renderPrefs: string;
|
||||||
|
/** Layout fingerprint of the archived PDF; null when nothing is archived. */
|
||||||
|
archivedFingerprint?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user