feat(export): CSV export scope — All / current filter / date range

Finishes the transactions-export partial. The dialog now offers three scopes:
- All transactions (unchanged default; exported with no copy).
- Current History filter — the Transactions tab publishes a TxFilterSnapshot of
  the visible legs (a merged autoshield row contributes both its send+receive
  legs, so the CSV stays a per-leg record like an unfiltered export); the radio
  is disabled with an explanation when no filter is active, and a snapshot that
  predates the current tx list (revision mismatch) is refused rather than
  silently exporting everything.
- Date range (YYYY-MM-DD, inclusive end-of-day; pending/dateless rows excluded
  with a note; invalid/reversed dates blocked inline).
The live count reflects what will actually be exported. formatTransactionsCsv
and the atomic write are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 00:19:24 -05:00
co-authored by Claude Opus 4.8
parent 8961cbdfc9
commit 8b46ead72a
4 changed files with 161 additions and 5 deletions
+95 -5
View File
@@ -7,6 +7,7 @@
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../data/transaction_csv.h" #include "../../data/transaction_csv.h"
#include "transactions_tab.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
@@ -29,6 +30,24 @@ static bool s_open = false;
static char s_filename[256] = ""; static char s_filename[256] = "";
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
static std::string s_error; // error message on failure; empty otherwise static std::string s_error; // error message on failure; empty otherwise
static int s_scope = 0; // 0 = all, 1 = current History filter, 2 = date range
static char s_from[16] = ""; // YYYY-MM-DD (date-range scope)
static char s_to[16] = "";
// Parse a YYYY-MM-DD string to the local-time start-of-day unix seconds. Returns false on empty/invalid.
static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
if (!s || !*s) return false;
int y = 0, mo = 0, d = 0;
if (std::sscanf(s, "%d-%d-%d", &y, &mo, &d) != 3) return false;
if (y < 1970 || y > 9999 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;
std::tm tm{}; tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; tm.tm_isdst = -1;
std::time_t t = std::mktime(&tm);
if (t == (std::time_t)-1) return false;
// Reject rollover (e.g. Feb 31 → Mar) by requiring the normalized fields to round-trip.
if (tm.tm_year != y - 1900 || tm.tm_mon != mo - 1 || tm.tm_mday != d) return false;
outStartOfDay = (std::int64_t)t;
return true;
}
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_', // Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv. // no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
@@ -60,6 +79,10 @@ void ExportTransactionsDialog::show()
s_open = true; s_open = true;
s_saved_path.clear(); s_saved_path.clear();
s_error.clear(); s_error.clear();
// Pre-select the current History filter only when one is actually applied.
s_scope = GetTransactionsFilterSnapshot().active ? 1 : 0;
s_from[0] = '\0';
s_to[0] = '\0';
// Generate default filename with timestamp // Generate default filename with timestamp
std::time_t now = std::time(nullptr); std::time_t now = std::time(nullptr);
@@ -101,8 +124,64 @@ void ExportTransactionsDialog::render(App* app)
if (!m::BeginOverlayDialog(ov)) return; if (!m::BeginOverlayDialog(ov)) return;
const auto& state = app->getWalletState(); const auto& state = app->getWalletState();
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
ImGui::Text(TR("export_tx_count"), state.transactions.size()); // ---- Export scope ----
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("export_tx_scope"));
ImGui::RadioButton(TR("export_tx_scope_all"), &s_scope, 0);
if (!filterUsable) ImGui::BeginDisabled();
ImGui::RadioButton(TR("export_tx_scope_filter"), &s_scope, 1);
if (!filterUsable) ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !filterUsable)
m::Tooltip("%s", TR("export_tx_scope_filter_none"));
if (s_scope == 1 && filterUsable && !snap.description.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "(%s)", snap.description.c_str());
}
ImGui::RadioButton(TR("export_tx_scope_range"), &s_scope, 2);
std::int64_t fromT = 0, toT = 0; bool fromOk = false, toOk = false, rangeBad = false;
if (s_scope == 2) {
ImGui::Indent();
const float dw = 130.0f * Layout::dpiScale();
ImGui::SetNextItemWidth(dw);
ImGui::InputTextWithHint("##exfrom", TR("export_tx_range_from"), s_from, sizeof(s_from));
ImGui::SameLine();
ImGui::SetNextItemWidth(dw);
ImGui::InputTextWithHint("##exto", TR("export_tx_range_to"), s_to, sizeof(s_to));
fromOk = parseYmd(s_from, fromT);
toOk = parseYmd(s_to, toT);
if (toOk) toT += 24 * 3600 - 1; // inclusive end-of-day
if ((s_from[0] && !fromOk) || (s_to[0] && !toOk)) rangeBad = true;
if (fromOk && toOk && fromT > toT) rangeBad = true;
if (rangeBad)
ImGui::TextColored(m::ErrorVec4(), "%s", TR("export_tx_range_invalid"));
else
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("export_tx_range_pending_note"));
ImGui::Unindent();
}
// Resolve the subset for the chosen scope (scopeOk=false ⇒ can't export: stale snapshot / bad range).
std::vector<size_t> subsetIdx; bool scopeOk = true;
if (s_scope == 1) {
if (!filterUsable) scopeOk = false;
else for (size_t i : snap.indices) if (i < state.transactions.size()) subsetIdx.push_back(i);
} else if (s_scope == 2) {
if (rangeBad) scopeOk = false;
else for (size_t i = 0; i < state.transactions.size(); ++i) {
const auto& tx = state.transactions[i];
if (tx.timestamp <= 0) continue; // pending rows have no date
if (fromOk && tx.timestamp < fromT) continue;
if (toOk && tx.timestamp > toT) continue;
subsetIdx.push_back(i);
}
} else {
for (size_t i = 0; i < state.transactions.size(); ++i) subsetIdx.push_back(i);
}
ImGui::Spacing();
ImGui::Text(TR("export_tx_count"), scopeOk ? subsetIdx.size() : (size_t)0);
ImGui::Spacing(); ImGui::Spacing();
// Filename + save-location hint. // Filename + save-location hint.
@@ -138,7 +217,10 @@ void ExportTransactionsDialog::render(App* app)
if (doExport) { if (doExport) {
s_saved_path.clear(); s_saved_path.clear();
s_error.clear(); s_error.clear();
if (state.transactions.empty()) { if (!scopeOk) {
// Stale filter snapshot (tx list changed) or an invalid date range — never silently export all.
Notifications::instance().warning(s_scope == 1 ? TR("export_tx_scope_stale") : TR("export_tx_range_invalid"));
} else if (subsetIdx.empty()) {
Notifications::instance().warning(TR("export_tx_none")); Notifications::instance().warning(TR("export_tx_none"));
} else { } else {
std::string configDir = util::Platform::getConfigDir(); std::string configDir = util::Platform::getConfigDir();
@@ -147,9 +229,17 @@ void ExportTransactionsDialog::render(App* app)
std::string safeName = sanitizeExportFilename(s_filename); std::string safeName = sanitizeExportFilename(s_filename);
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str()); snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
std::string filepath = configDir + "/" + safeName; std::string filepath = configDir + "/" + safeName;
// Build in memory, then write atomically (temp + fsync + rename): a disk-full / // Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast. // (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
const std::string csv = data::formatTransactionsCsv(state.transactions); std::string csv;
if (s_scope == 0) {
csv = data::formatTransactionsCsv(state.transactions);
} else {
std::vector<TransactionInfo> subset;
subset.reserve(subsetIdx.size());
for (size_t i : subsetIdx) subset.push_back(state.transactions[i]);
csv = data::formatTransactionsCsv(subset);
}
if (!util::Platform::writeFileAtomically(filepath, csv)) { if (!util::Platform::writeFileAtomically(filepath, csv)) {
s_error = TR("export_tx_file_fail"); s_error = TR("export_tx_file_fail");
Notifications::instance().error(TR("export_tx_file_fail")); Notifications::instance().error(TR("export_tx_file_fail"));
+34
View File
@@ -36,6 +36,11 @@ namespace ui {
using namespace material; using namespace material;
// Latest History-tab filter snapshot, published from the filtered-list rebuild below. Used by the
// transactions CSV export to offer "export only the current filter".
static TxFilterSnapshot s_filter_snapshot;
const TxFilterSnapshot& GetTransactionsFilterSnapshot() { return s_filter_snapshot; }
static std::string TrId(const char* key, const char* id) { static std::string TrId(const char* key, const char* id) {
return std::string(TR(key)) + "##" + id; return std::string(TR(key)) + "##" + id;
} }
@@ -565,6 +570,35 @@ void RenderTransactionsTab(App* app)
s_filtered_search = search_str; s_filtered_search = search_str;
s_filtered_chat_rev = chatRev; s_filtered_chat_rev = chatRev;
s_filtered_valid = true; s_filtered_valid = true;
// Publish a snapshot of the visible set for "export current filter". A merged autoshield row
// contributes BOTH original legs, so the CSV stays a per-leg record like an unfiltered export.
s_filter_snapshot.indices.clear();
s_filter_snapshot.indices.reserve(s_filtered_cache.size() * 2);
for (size_t fi : s_filtered_cache) {
const auto& dtx = display_txns[fi];
if (dtx.is_shield) {
if (dtx.send_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.send_idx));
if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.recv_idx));
} else if (dtx.orig_idx >= 0) {
s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.orig_idx));
}
}
std::sort(s_filter_snapshot.indices.begin(), s_filter_snapshot.indices.end());
s_filter_snapshot.active = (type_filter != 0 || !search_str.empty());
s_filter_snapshot.txRevision = contentKey;
s_filter_snapshot.valid = true;
{
const char* typeNames[] = { "", TR("sent_filter"), TR("received_filter"),
TR("mined_filter"), TR("chat_filter") };
std::string desc = (type_filter >= 1 && type_filter <= 4) ? typeNames[type_filter] : "";
if (!search_str.empty()) {
if (!desc.empty()) desc += ", ";
desc += "\"" + search_str + "\"";
}
if (desc.empty()) desc = TR("all_filter");
s_filter_snapshot.description = desc;
}
} }
const std::vector<size_t>& filtered_indices = s_filtered_cache; const std::vector<size_t>& filtered_indices = s_filtered_cache;
filtered_count = static_cast<int>(filtered_indices.size()); filtered_count = static_cast<int>(filtered_indices.size());
+22
View File
@@ -5,12 +5,34 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <cstdint>
#include <cstddef>
namespace dragonx { namespace dragonx {
class App; class App;
namespace ui { namespace ui {
/**
* @brief Snapshot of what the History tab currently shows, for "export current filter".
*
* `indices` are positions into WalletState::transactions of every leg currently visible (a merged
* autoshield row contributes both its send and receive legs), sorted ascending. Published only when the
* filtered list is (re)built, so it reflects the last state the History tab rendered. `txRevision` lets a
* consumer reject a snapshot that predates the current tx list.
*/
struct TxFilterSnapshot {
bool valid = false;
bool active = false; // a type filter and/or search text is applied
std::uint64_t txRevision = 0;
std::string description; // human label of the active filter
std::vector<std::size_t> indices;
};
/// The latest History-tab filter snapshot (see TxFilterSnapshot).
const TxFilterSnapshot& GetTransactionsFilterSnapshot();
/** /**
* @brief Render the Transactions tab * @brief Render the Transactions tab
* Shows transaction history * Shows transaction history
+10
View File
@@ -2131,6 +2131,16 @@ void I18n::loadBuiltinEnglish()
strings_["export_tx_none"] = "No transactions to export"; strings_["export_tx_none"] = "No transactions to export";
strings_["export_tx_success"] = "Transactions exported successfully"; strings_["export_tx_success"] = "Transactions exported successfully";
strings_["export_tx_open_folder"] = "Open folder"; strings_["export_tx_open_folder"] = "Open folder";
strings_["export_tx_scope"] = "Export scope";
strings_["export_tx_scope_all"] = "All transactions";
strings_["export_tx_scope_filter"] = "Current History filter";
strings_["export_tx_scope_range"] = "Date range";
strings_["export_tx_scope_filter_none"] = "No filter is active on the History tab";
strings_["export_tx_scope_stale"] = "The History filter changed while loading. Close and reopen this dialog.";
strings_["export_tx_range_from"] = "From (YYYY-MM-DD)";
strings_["export_tx_range_to"] = "To (YYYY-MM-DD)";
strings_["export_tx_range_invalid"] = "Enter valid dates as YYYY-MM-DD, with From on or before To";
strings_["export_tx_range_pending_note"] = "Pending transactions have no date and are not included in a date range.";
strings_["export_tx_title"] = "Export Transactions to CSV"; strings_["export_tx_title"] = "Export Transactions to CSV";
// --- Import Key Dialog --- // --- Import Key Dialog ---