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>
258 lines
11 KiB
C++
258 lines
11 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "export_transactions_dialog.h"
|
|
#include "../../app.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/platform.h"
|
|
#include "../../data/transaction_csv.h"
|
|
#include "transactions_tab.h"
|
|
#include "../notifications.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/type.h"
|
|
#include "../widgets/copy_field.h"
|
|
#include "../theme.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "imgui.h"
|
|
|
|
#include <string>
|
|
#include <ctime>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
// Static state
|
|
static bool s_open = false;
|
|
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_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 → '_',
|
|
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
|
|
static std::string sanitizeExportFilename(std::string name) {
|
|
const size_t slash = name.find_last_of("/\\");
|
|
if (slash != std::string::npos) name = name.substr(slash + 1);
|
|
auto notspace = [](unsigned char c) { return !std::isspace(c); };
|
|
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
|
|
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
|
|
for (char& c : name) {
|
|
const unsigned char u = static_cast<unsigned char>(c);
|
|
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
|
|
c = '_';
|
|
}
|
|
const size_t firstGood = name.find_first_not_of('.');
|
|
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
|
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
|
if (name.size() > 120) name.resize(120);
|
|
std::string lower = name;
|
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
|
|
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
|
|
return name;
|
|
}
|
|
|
|
void ExportTransactionsDialog::show()
|
|
{
|
|
s_open = true;
|
|
s_saved_path.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
|
|
std::time_t now = std::time(nullptr);
|
|
char timebuf[32];
|
|
std::tm nowTm{};
|
|
#ifdef _WIN32
|
|
const bool nowOk = localtime_s(&nowTm, &now) == 0;
|
|
#else
|
|
const bool nowOk = localtime_r(&now, &nowTm) != nullptr;
|
|
#endif
|
|
timebuf[0] = '\0';
|
|
if (nowOk) std::strftime(timebuf, sizeof(timebuf), "%Y%m%d_%H%M%S", &nowTm);
|
|
snprintf(s_filename, sizeof(s_filename), "dragonx_transactions_%s.csv", timebuf);
|
|
}
|
|
|
|
bool ExportTransactionsDialog::isOpen()
|
|
{
|
|
return s_open;
|
|
}
|
|
|
|
void ExportTransactionsDialog::hide()
|
|
{
|
|
s_open = false;
|
|
s_saved_path.clear();
|
|
s_error.clear();
|
|
}
|
|
|
|
void ExportTransactionsDialog::render(App* app)
|
|
{
|
|
if (!s_open) return;
|
|
namespace m = material;
|
|
|
|
m::OverlayDialogSpec ov;
|
|
ov.title = TR("export_tx_title");
|
|
ov.p_open = &s_open;
|
|
ov.style = m::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 520.0f;
|
|
ov.idSuffix = "exporttx";
|
|
if (!m::BeginOverlayDialog(ov)) return;
|
|
|
|
const auto& state = app->getWalletState();
|
|
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
|
|
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
|
|
|
|
// ---- 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();
|
|
|
|
// Filename + save-location hint.
|
|
m::LabeledInput(TR("output_filename"), "##Filename", s_filename, sizeof(s_filename));
|
|
ImGui::Spacing();
|
|
// Show the real, resolved config directory (the old hardcoded ~/.config path was wrong on Windows/macOS).
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s %s",
|
|
TR("file_save_location"), util::Platform::getConfigDir().c_str());
|
|
|
|
// Result — success shows the saved path as a copy field; failure shows a red line.
|
|
if (!s_saved_path.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
|
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
|
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
|
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
|
|
util::Platform::openFolder(util::Platform::getConfigDir());
|
|
} else if (!s_error.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", s_error.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
bool doExport = false, doClose = false;
|
|
m::DialogActionFooter(TR("export"), true, TR("close"), doExport, doClose);
|
|
if (doClose) s_open = false;
|
|
if (doExport) {
|
|
s_saved_path.clear();
|
|
s_error.clear();
|
|
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"));
|
|
} else {
|
|
std::string configDir = util::Platform::getConfigDir();
|
|
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
|
// the config dir by a pasted path).
|
|
std::string safeName = sanitizeExportFilename(s_filename);
|
|
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
|
std::string filepath = configDir + "/" + safeName;
|
|
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
|
|
// (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
|
|
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)) {
|
|
s_error = TR("export_tx_file_fail");
|
|
Notifications::instance().error(TR("export_tx_file_fail"));
|
|
} else {
|
|
s_saved_path = filepath;
|
|
Notifications::instance().success(TR("export_tx_success"), 5.0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
m::EndOverlayDialog();
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|