From 8b46ead72af2878778cd7d779721cf83a60f8f11 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 00:19:24 -0500 Subject: [PATCH] =?UTF-8?q?feat(export):=20CSV=20export=20scope=20?= =?UTF-8?q?=E2=80=94=20All=20/=20current=20filter=20/=20date=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ui/windows/export_transactions_dialog.cpp | 100 +++++++++++++++++- src/ui/windows/transactions_tab.cpp | 34 ++++++ src/ui/windows/transactions_tab.h | 22 ++++ src/util/i18n.cpp | 10 ++ 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/src/ui/windows/export_transactions_dialog.cpp b/src/ui/windows/export_transactions_dialog.cpp index 899f2a2..5a70d61 100644 --- a/src/ui/windows/export_transactions_dialog.cpp +++ b/src/ui/windows/export_transactions_dialog.cpp @@ -7,6 +7,7 @@ #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" @@ -29,6 +30,24 @@ 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. @@ -60,6 +79,10 @@ 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); @@ -101,8 +124,64 @@ void ExportTransactionsDialog::render(App* app) 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; - 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 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. @@ -138,7 +217,10 @@ void ExportTransactionsDialog::render(App* app) if (doExport) { s_saved_path.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")); } else { std::string configDir = util::Platform::getConfigDir(); @@ -147,9 +229,17 @@ void ExportTransactionsDialog::render(App* app) std::string safeName = sanitizeExportFilename(s_filename); snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str()); std::string filepath = configDir + "/" + safeName; - // Build in memory, then write atomically (temp + fsync + rename): a disk-full / - // removable-media failure is reported instead of leaving a truncated CSV behind a success toast. - const std::string csv = data::formatTransactionsCsv(state.transactions); + // 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 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")); diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index dd369eb..6791531 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -36,6 +36,11 @@ namespace ui { 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) { return std::string(TR(key)) + "##" + id; } @@ -565,6 +570,35 @@ void RenderTransactionsTab(App* app) s_filtered_search = search_str; s_filtered_chat_rev = chatRev; 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(dtx.send_idx)); + if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast(dtx.recv_idx)); + } else if (dtx.orig_idx >= 0) { + s_filter_snapshot.indices.push_back(static_cast(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& filtered_indices = s_filtered_cache; filtered_count = static_cast(filtered_indices.size()); diff --git a/src/ui/windows/transactions_tab.h b/src/ui/windows/transactions_tab.h index 8229613..5a7cf9f 100644 --- a/src/ui/windows/transactions_tab.h +++ b/src/ui/windows/transactions_tab.h @@ -5,12 +5,34 @@ #pragma once #include +#include +#include +#include namespace dragonx { class App; 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 indices; +}; + +/// The latest History-tab filter snapshot (see TxFilterSnapshot). +const TxFilterSnapshot& GetTransactionsFilterSnapshot(); + /** * @brief Render the Transactions tab * Shows transaction history diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 99fff58..88bef7d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -2131,6 +2131,16 @@ void I18n::loadBuiltinEnglish() strings_["export_tx_none"] = "No transactions to export"; strings_["export_tx_success"] = "Transactions exported successfully"; 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"; // --- Import Key Dialog ---