// 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 #include #include #include 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(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(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 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 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