From 9b68f91c0bf3f666267a7fb6fbb522623fff0263 Mon Sep 17 00:00:00 2001 From: AnotherTest Date: Sat, 20 Mar 2021 14:00:49 +0330 Subject: [PATCH] Spreadsheet: Add an export wizard, and support for custom CSV exports Fixes #4269. --- .../Applications/Spreadsheet/CMakeLists.txt | 3 + .../Applications/Spreadsheet/ExportDialog.cpp | 356 ++++++++++++++++++ .../Applications/Spreadsheet/ExportDialog.h | 79 ++++ .../Applications/Spreadsheet/Workbook.cpp | 32 +- .../Applications/Spreadsheet/csv_export.gml | 164 ++++++++ Userland/Applications/Spreadsheet/main.cpp | 8 +- 6 files changed, 614 insertions(+), 28 deletions(-) create mode 100644 Userland/Applications/Spreadsheet/ExportDialog.cpp create mode 100644 Userland/Applications/Spreadsheet/ExportDialog.h create mode 100644 Userland/Applications/Spreadsheet/csv_export.gml diff --git a/Userland/Applications/Spreadsheet/CMakeLists.txt b/Userland/Applications/Spreadsheet/CMakeLists.txt index 0735ae12a7..15a9f6c020 100644 --- a/Userland/Applications/Spreadsheet/CMakeLists.txt +++ b/Userland/Applications/Spreadsheet/CMakeLists.txt @@ -1,6 +1,7 @@ compile_gml(CondFormatting.gml CondFormattingGML.h cond_fmt_gml) compile_gml(CondView.gml CondFormattingViewGML.h cond_fmt_view_gml) compile_gml(csv_import.gml CSVImportGML.h csv_import_gml) +compile_gml(csv_export.gml CSVExportGML.h csv_export_gml) compile_gml(select_format_page.gml FormatSelectionPageGML.h select_format_page_gml) set(SOURCES @@ -15,6 +16,7 @@ set(SOURCES CellTypeDialog.cpp CondFormattingGML.h CondFormattingViewGML.h + ExportDialog.cpp HelpWindow.cpp ImportDialog.cpp JSIntegration.cpp @@ -28,6 +30,7 @@ set(SOURCES ) set(GENERATED_SOURCES + CSVExportGML.h CSVImportGML.h FormatSelectionPageGML.h ) diff --git a/Userland/Applications/Spreadsheet/ExportDialog.cpp b/Userland/Applications/Spreadsheet/ExportDialog.cpp new file mode 100644 index 0000000000..f911558396 --- /dev/null +++ b/Userland/Applications/Spreadsheet/ExportDialog.cpp @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2020-2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ExportDialog.h" +#include "Spreadsheet.h" +#include "Workbook.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// This is defined in ImportDialog.cpp, we can't include it twice, since the generated symbol is exported. +extern const char* select_format_page_gml; + +namespace Spreadsheet { + +CSVExportDialogPage::CSVExportDialogPage(const Sheet& sheet) + : m_data(sheet.to_xsv()) +{ + m_headers.append(m_data.take_first()); + + auto temp_template = String::formatted("{}/spreadsheet-csv-export.{}.XXXXXX", Core::StandardPaths::tempfile_directory(), getpid()); + auto temp_path = ByteBuffer::create_uninitialized(temp_template.length() + 1); + auto buf = reinterpret_cast(temp_path.data()); + auto copy_ok = temp_template.copy_characters_to_buffer(buf, temp_path.size()); + VERIFY(copy_ok); + + int fd = mkstemp(buf); + if (fd < 0) { + perror("mkstemp"); + // Just let the operation fail cleanly later. + } else { + unlink(buf); + m_temp_output_file_path = temp_path; + } + + m_page = GUI::WizardPage::construct( + "CSV Export Options", + "Please select the options for the csv file you wish to export to"); + + m_page->body_widget().load_from_gml(csv_export_gml); + m_page->set_is_final_page(true); + + m_delimiter_comma_radio = m_page->body_widget().find_descendant_of_type_named("delimiter_comma_radio"); + m_delimiter_semicolon_radio = m_page->body_widget().find_descendant_of_type_named("delimiter_semicolon_radio"); + m_delimiter_tab_radio = m_page->body_widget().find_descendant_of_type_named("delimiter_tab_radio"); + m_delimiter_space_radio = m_page->body_widget().find_descendant_of_type_named("delimiter_space_radio"); + m_delimiter_other_radio = m_page->body_widget().find_descendant_of_type_named("delimiter_other_radio"); + m_delimiter_other_text_box = m_page->body_widget().find_descendant_of_type_named("delimiter_other_text_box"); + m_quote_single_radio = m_page->body_widget().find_descendant_of_type_named("quote_single_radio"); + m_quote_double_radio = m_page->body_widget().find_descendant_of_type_named("quote_double_radio"); + m_quote_other_radio = m_page->body_widget().find_descendant_of_type_named("quote_other_radio"); + m_quote_other_text_box = m_page->body_widget().find_descendant_of_type_named("quote_other_text_box"); + m_quote_escape_combo_box = m_page->body_widget().find_descendant_of_type_named("quote_escape_combo_box"); + m_export_header_check_box = m_page->body_widget().find_descendant_of_type_named("export_header_check_box"); + m_quote_all_fields_check_box = m_page->body_widget().find_descendant_of_type_named("quote_all_fields_check_box"); + m_data_preview_text_editor = m_page->body_widget().find_descendant_of_type_named("data_preview_text_editor"); + + m_data_preview_text_editor->set_should_hide_unnecessary_scrollbars(true); + + Vector quote_escape_items { + // Note: Keep in sync with Writer::WriterTraits::QuoteEscape. + "Repeat", + "Backslash", + }; + m_quote_escape_combo_box->set_model(GUI::ItemListModel::create(quote_escape_items)); + + // By default, use commas, double quotes with repeat, disable headers, and quote only the fields that require quoting. + m_delimiter_comma_radio->set_checked(true); + m_quote_double_radio->set_checked(true); + m_quote_escape_combo_box->set_selected_index(0); // Repeat + + m_delimiter_comma_radio->on_checked = [&](auto) { update_preview(); }; + m_delimiter_semicolon_radio->on_checked = [&](auto) { update_preview(); }; + m_delimiter_tab_radio->on_checked = [&](auto) { update_preview(); }; + m_delimiter_space_radio->on_checked = [&](auto) { update_preview(); }; + m_delimiter_other_radio->on_checked = [&](auto) { update_preview(); }; + m_delimiter_other_text_box->on_change = [&](auto&) { + if (m_delimiter_other_radio->is_checked()) + update_preview(); + }; + m_quote_single_radio->on_checked = [&](auto) { update_preview(); }; + m_quote_double_radio->on_checked = [&](auto) { update_preview(); }; + m_quote_other_radio->on_checked = [&](auto) { update_preview(); }; + m_quote_other_text_box->on_change = [&](auto&) { + if (m_quote_other_radio->is_checked()) + update_preview(); + }; + m_quote_escape_combo_box->on_change = [&](auto&) { update_preview(); }; + m_export_header_check_box->on_checked = [&](auto) { update_preview(); }; + m_quote_all_fields_check_box->on_checked = [&](auto) { update_preview(); }; + + update_preview(); +} + +auto CSVExportDialogPage::make_writer() -> Optional +{ + String delimiter; + String quote; + Writer::WriterTraits::QuoteEscape quote_escape; + + // Delimiter + if (m_delimiter_other_radio->is_checked()) + delimiter = m_delimiter_other_text_box->text(); + else if (m_delimiter_comma_radio->is_checked()) + delimiter = ","; + else if (m_delimiter_semicolon_radio->is_checked()) + delimiter = ";"; + else if (m_delimiter_tab_radio->is_checked()) + delimiter = "\t"; + else if (m_delimiter_space_radio->is_checked()) + delimiter = " "; + else + return {}; + + // Quote separator + if (m_quote_other_radio->is_checked()) + quote = m_quote_other_text_box->text(); + else if (m_quote_single_radio->is_checked()) + quote = "'"; + else if (m_quote_double_radio->is_checked()) + quote = "\""; + else + return {}; + + // Quote escape + auto index = m_quote_escape_combo_box->selected_index(); + if (index == 0) + quote_escape = Writer::WriterTraits::Repeat; + else if (index == 1) + quote_escape = Writer::WriterTraits::Backslash; + else + return {}; + + auto should_export_headers = m_export_header_check_box->is_checked(); + auto should_quote_all_fields = m_quote_all_fields_check_box->is_checked(); + + if (quote.is_empty() || delimiter.is_empty()) + return {}; + + Writer::WriterTraits traits { + move(delimiter), + move(quote), + quote_escape, + }; + + auto behaviours = Writer::default_behaviours(); + Vector empty_headers; + auto* headers = &empty_headers; + + if (should_export_headers) { + behaviours = behaviours | Writer::WriterBehaviour::WriteHeaders; + headers = &m_headers; + } + + if (should_quote_all_fields) + behaviours = behaviours | Writer::WriterBehaviour::QuoteAll; + + // Note that the stream is used only by the ctor. + auto stream = Core::OutputFileStream::open(m_temp_output_file_path); + if (stream.is_error()) { + dbgln("Cannot open {} for writing: {}", m_temp_output_file_path, stream.error()); + return {}; + } + XSV writer(stream.value(), m_data, traits, *headers, behaviours); + + if (stream.value().has_any_error()) { + dbgln("Write error when making preview"); + return {}; + } + + return writer; +} + +void CSVExportDialogPage::update_preview() + +{ + m_previously_made_writer = make_writer(); + if (!m_previously_made_writer.has_value()) { + fail:; + m_data_preview_text_editor->set_text({}); + return; + } + + auto file_or_error = Core::File::open( + m_temp_output_file_path, + Core::IODevice::ReadOnly); + if (file_or_error.is_error()) + goto fail; + + auto& file = *file_or_error.value(); + StringBuilder builder; + size_t line = 0; + while (file.can_read_line()) { + if (++line == 8) + break; + + builder.append(file.read_line()); + builder.append('\n'); + } + m_data_preview_text_editor->set_text(builder.string_view()); + m_data_preview_text_editor->update(); +} + +Result CSVExportDialogPage::move_into(const String& target) +{ + auto& source = m_temp_output_file_path; + + // First, try rename(). + auto rc = rename(source.characters(), target.characters()); + if (rc == 0) + return {}; + + auto saved_errno = errno; + if (saved_errno == EXDEV) { + // Can't do that, copy it instead. + auto result = Core::File::copy_file_or_directory( + target, source, + Core::File::RecursionMode::Disallowed, + Core::File::LinkMode::Disallowed, + Core::File::AddDuplicateFileMarker::No); + + if (result.is_error()) + return String { result.error().error_code.string() }; + + return {}; + } + + perror("rename"); + return String { strerror(saved_errno) }; +} + +Result ExportDialog::make_and_run_for(StringView mime, Core::File& file, Workbook& workbook) +{ + auto wizard = GUI::WizardDialog::construct(GUI::Application::the()->active_window()); + wizard->set_title("File Export Wizard"); + wizard->set_icon(GUI::Icon::default_icon("app-spreadsheet").bitmap_for_size(16)); + + auto export_xsv = [&]() -> Result { + // FIXME: Prompt for the user to select a specific sheet to export + // For now, export the first sheet (if available) + if (!workbook.has_sheets()) + return String { "The workbook has no sheets to export!" }; + + CSVExportDialogPage page { workbook.sheets().first() }; + wizard->replace_page(page.page()); + auto result = wizard->exec(); + + if (result == GUI::Dialog::ExecResult::ExecOK) { + auto& writer = page.writer(); + if (!writer.has_value()) + return String { "CSV Export failed" }; + if (writer->has_error()) + return String::formatted("CSV Export failed: {}", writer->error_string()); + + // No error, move the temp file to the expected location + return page.move_into(file.filename()); + } else { + return String { "CSV Export was cancelled" }; + } + }; + + auto export_worksheet = [&]() -> Result { + JsonArray array; + for (auto& sheet : workbook.sheets()) + array.append(sheet.to_json()); + + auto file_content = array.to_string(); + bool result = file.write(file_content); + if (!result) { + int error_number = errno; + StringBuilder sb; + sb.append("Unable to save file. Error: "); + sb.append(strerror(error_number)); + + return sb.to_string(); + } + + return {}; + }; + + if (mime == "text/csv") { + return export_xsv(); + } else if (mime == "text/plain" && file.filename().ends_with(".sheets")) { + return export_worksheet(); + } else { + auto page = GUI::WizardPage::construct( + "Export File Format", + String::formatted("Select the format you wish to export to '{}' as", LexicalPath { file.filename() }.basename())); + + page->on_next_page = [] { return nullptr; }; + + page->body_widget().load_from_gml(select_format_page_gml); + auto format_combo_box = page->body_widget().find_descendant_of_type_named("select_format_page_format_combo_box"); + + Vector supported_formats { + "CSV (text/csv)", + "Spreadsheet Worksheet", + }; + format_combo_box->set_model(GUI::ItemListModel::create(supported_formats)); + + wizard->push_page(page); + + if (wizard->exec() != GUI::Dialog::ExecResult::ExecOK) + return String { "Export was cancelled" }; + + if (format_combo_box->selected_index() == 0) + return export_xsv(); + + if (format_combo_box->selected_index() == 1) + return export_worksheet(); + + VERIFY_NOT_REACHED(); + } +} + +}; diff --git a/Userland/Applications/Spreadsheet/ExportDialog.h b/Userland/Applications/Spreadsheet/ExportDialog.h new file mode 100644 index 0000000000..d1bb80816d --- /dev/null +++ b/Userland/Applications/Spreadsheet/ExportDialog.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020-2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Writers/XSV.h" +#include +#include +#include +#include + +namespace Spreadsheet { + +class Sheet; +class Workbook; + +struct CSVExportDialogPage { + using XSV = Writer::XSV>, Vector>; + + explicit CSVExportDialogPage(const Sheet&); + + NonnullRefPtr page() { return *m_page; } + Optional& writer() { return m_previously_made_writer; } + Result move_into(const String& target); + +protected: + void update_preview(); + Optional make_writer(); + +private: + Vector> m_data; + Vector m_headers; + Optional m_previously_made_writer; + RefPtr m_page; + RefPtr m_delimiter_comma_radio; + RefPtr m_delimiter_semicolon_radio; + RefPtr m_delimiter_tab_radio; + RefPtr m_delimiter_space_radio; + RefPtr m_delimiter_other_radio; + RefPtr m_delimiter_other_text_box; + RefPtr m_quote_single_radio; + RefPtr m_quote_double_radio; + RefPtr m_quote_other_radio; + RefPtr m_quote_other_text_box; + RefPtr m_quote_escape_combo_box; + RefPtr m_export_header_check_box; + RefPtr m_quote_all_fields_check_box; + RefPtr m_data_preview_text_editor; + String m_temp_output_file_path; +}; + +struct ExportDialog { + static Result make_and_run_for(StringView mime, Core::File& file, Workbook&); +}; + +} diff --git a/Userland/Applications/Spreadsheet/Workbook.cpp b/Userland/Applications/Spreadsheet/Workbook.cpp index 878526da22..0f023d727a 100644 --- a/Userland/Applications/Spreadsheet/Workbook.cpp +++ b/Userland/Applications/Spreadsheet/Workbook.cpp @@ -25,6 +25,7 @@ */ #include "Workbook.h" +#include "ExportDialog.h" #include "ImportDialog.h" #include "JSIntegration.h" #include "Readers/CSV.h" @@ -111,33 +112,10 @@ Result Workbook::save(const StringView& filename) return sb.to_string(); } - if (mime == "text/csv") { - // FIXME: Prompt the user for settings and which sheet to export. - Core::OutputFileStream stream { file }; - auto data = m_sheets[0].to_xsv(); - auto header_string = data.take_first(); - Vector headers; - for (auto& str : header_string) - headers.append(str); - Writer::CSV csv { stream, data, headers }; - if (csv.has_error()) - return String::formatted("Unable to save file, CSV writer error: {}", csv.error_string()); - } else { - JsonArray array; - for (auto& sheet : m_sheets) - array.append(sheet.to_json()); - - auto file_content = array.to_string(); - bool result = file->write(file_content); - if (!result) { - int error_number = errno; - StringBuilder sb; - sb.append("Unable to save file. Error: "); - sb.append(strerror(error_number)); - - return sb.to_string(); - } - } + // Make an export dialog, we might need to import it. + auto result = ExportDialog::make_and_run_for(mime, *file, *this); + if (result.is_error()) + return result.error(); set_filename(filename); set_dirty(false); diff --git a/Userland/Applications/Spreadsheet/csv_export.gml b/Userland/Applications/Spreadsheet/csv_export.gml new file mode 100644 index 0000000000..d2def8652c --- /dev/null +++ b/Userland/Applications/Spreadsheet/csv_export.gml @@ -0,0 +1,164 @@ +@GUI::Widget { + layout: @GUI::VerticalBoxLayout { + margins: [20, 20, 20, 20] + } + + @GUI::HorizontalSplitter { + @GUI::Widget { + name: "csv_options" + + layout: @GUI::VerticalBoxLayout { + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::GroupBox { + title: "Delimiter" + + layout: @GUI::VerticalBoxLayout { + // FIXME: This is working around the fact that group boxes don't allocate space for their title and border! + margins: [10, 20, 10, 10] + } + + @GUI::RadioButton { + name: "delimiter_comma_radio" + text: "Comma" + autosize: true + } + + @GUI::RadioButton { + name: "delimiter_semicolon_radio" + text: "Semicolon" + autosize: true + } + + @GUI::RadioButton { + name: "delimiter_tab_radio" + text: "Tab" + autosize: true + } + + @GUI::RadioButton { + name: "delimiter_space_radio" + text: "Space" + autosize: true + } + + @GUI::Widget { + fixed_height: 25 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::RadioButton { + name: "delimiter_other_radio" + text: "Other: " + autosize: true + } + + @GUI::TextBox { + name: "delimiter_other_text_box" + text: "" + } + } + } + + @GUI::GroupBox { + title: "Quote" + + layout: @GUI::VerticalBoxLayout { + // FIXME: This is working around the fact that group boxes don't allocate space for their title and border! + margins: [10, 20, 10, 10] + } + + @GUI::RadioButton { + name: "quote_single_radio" + text: "Single Quotes" + autosize: true + } + + @GUI::RadioButton { + name: "quote_double_radio" + text: "Double Quotes" + autosize: true + } + + @GUI::Widget { + fixed_height: 25 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::RadioButton { + name: "quote_other_radio" + text: "Other: " + autosize: true + } + + @GUI::TextBox { + name: "quote_other_text_box" + text: "" + } + } + + @GUI::Widget { + } + + @GUI::Widget { + fixed_height: 25 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Escape by " + autosize: true + } + + @GUI::ComboBox { + name: "quote_escape_combo_box" + model_only: true + } + } + + @GUI::Widget { + } + } + } + + @GUI::Widget { + fixed_height: 25 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::CheckBox { + name: "export_header_check_box" + text: "Export with headers" + } + + @GUI::CheckBox { + name: "quote_all_fields_check_box" + text: "Quote all fields" + } + } + } + + @GUI::GroupBox { + title: "Data Preview" + fixed_width: 150 + + layout: @GUI::VerticalBoxLayout { + // FIXME: This is working around the fact that group boxes don't allocate space for their title and border! + margins: [10, 20, 10, 10] + } + + @GUI::TextEditor { + name: "data_preview_text_editor" + mode: "ReadOnly" + } + } + } +} diff --git a/Userland/Applications/Spreadsheet/main.cpp b/Userland/Applications/Spreadsheet/main.cpp index 805b843365..41e8c32d18 100644 --- a/Userland/Applications/Spreadsheet/main.cpp +++ b/Userland/Applications/Spreadsheet/main.cpp @@ -48,7 +48,7 @@ int main(int argc, char* argv[]) auto app = GUI::Application::construct(argc, argv); - if (pledge("stdio recvfd sendfd thread rpath accept cpath wpath unix", nullptr) < 0) { + if (pledge("stdio recvfd sendfd thread rpath accept cpath wpath fattr unix", nullptr) < 0) { perror("pledge"); return 1; } @@ -72,6 +72,12 @@ int main(int argc, char* argv[]) return 1; } + // For writing temporary files when exporting. + if (unveil("/tmp", "crw") < 0) { + perror("unveil"); + return 1; + } + if (unveil("/etc", "r") < 0) { perror("unveil"); return 1;