Files
ObsidianDragon/src/ui/windows/export_transactions_dialog.cpp
T
DanSandClaude Opus 4.8 8b46ead72a 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>
2026-10-19 00:19:24 -05:00

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