From 586cb682014fd422b4f63ffd6942ac2e93f11481 Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Sun, 8 Oct 2023 14:30:08 +0800 Subject: [PATCH 1/7] [engine.i] qof_session_load|save_quiet --- bindings/engine.i | 41 +++++++++++++++++ libgnucash/engine/qof-backend.cpp | 75 +++++++++++++++++++++++++++++++ libgnucash/engine/qofbackend.h | 4 ++ 3 files changed, 120 insertions(+) diff --git a/bindings/engine.i b/bindings/engine.i index 7e849af8bbb..6a3a0f16fab 100644 --- a/bindings/engine.i +++ b/bindings/engine.i @@ -201,6 +201,41 @@ QofBook * qof_session_get_book (QofSession *session); // TODO: Unroll/remove const char *qof_session_get_url (QofSession *session); +/* note: copied from qofsession.h -- maintain manually until + qofsession can be %included properly */ +typedef enum +{ + SESSION_NORMAL_OPEN = 0, + SESSION_NEW_STORE = 2, + SESSION_NEW_OVERWRITE = 3, + SESSION_READ_ONLY = 4, + SESSION_BREAK_LOCK = 5 +} SessionOpenMode; + +%inline { +static void qof_session_save_quiet () +{ + QofSession *session = gnc_get_current_session(); + qof_session_save (session, [](const char* message, double percent){}); +} + +static bool qof_session_load_quiet (const char *filename, SessionOpenMode mode) +{ + gnc_clear_current_session(); + QofSession *session = gnc_get_current_session(); + qof_session_begin (session, filename, mode); + auto io_error = qof_session_get_error (session); + if (io_error != ERR_BACKEND_NO_ERR) + { + PWARN ("Loading error: %s", qof_backend_get_error_string (io_error)); + return false; + } + qof_session_load (session, [](const char* message, double percent){}); + return true; +} + +} + %ignore qof_print_date_time_buff; %ignore gnc_tm_free; %newobject qof_print_date; @@ -356,6 +391,12 @@ void qof_book_set_string_option(QofBook* book, const char* opt_name, const char* SET_ENUM("HOOK-REPORT"); SET_ENUM("HOOK-SAVE-OPTIONS"); + SET_ENUM("SESSION-NORMAL-OPEN"); + SET_ENUM("SESSION-NEW-STORE"); + SET_ENUM("SESSION-NEW-OVERWRITE"); + SET_ENUM("SESSION-READ-ONLY"); + SET_ENUM("SESSION-BREAK-LOCK"); + //SET_ENUM("GNC-ID-ACCOUNT"); SET_ENUM("QOF-ID-BOOK-SCM"); //SET_ENUM("GNC-ID-BUDGET"); diff --git a/libgnucash/engine/qof-backend.cpp b/libgnucash/engine/qof-backend.cpp index db03d0831e2..fe2bd06f9b1 100644 --- a/libgnucash/engine/qof-backend.cpp +++ b/libgnucash/engine/qof-backend.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include "qof-backend.hpp" @@ -140,6 +141,68 @@ QofBackend::release_backends() } } /***********************************************************************/ + +static const std::unordered_map qof_backend_error_map = +{ + { ERR_BACKEND_NO_ERR, nullptr }, + { ERR_BACKEND_NO_HANDLER, "no backend handler found for this access method (ENOSYS)" }, + { ERR_BACKEND_NO_BACKEND, "Backend * pointer was unexpectedly null" }, + { ERR_BACKEND_BAD_URL, "Can't parse url" }, + { ERR_BACKEND_NO_SUCH_DB, "the named database doesn't exist" }, + { ERR_BACKEND_CANT_CONNECT,"bad dbname/login/passwd or network failure" }, + { ERR_BACKEND_CONN_LOST, "Lost connection to server" }, + { ERR_BACKEND_LOCKED, "in use by another user (ETXTBSY)" }, + { ERR_BACKEND_STORE_EXISTS,"File exists, data would be destroyed" }, + { ERR_BACKEND_READONLY, "cannot write to file/directory" }, + { ERR_BACKEND_TOO_NEW, "file/db version newer than what we can read" }, + { ERR_BACKEND_DATA_CORRUPT,"data in db is corrupt" }, + { ERR_BACKEND_SERVER_ERR, "error in response from server" }, + { ERR_BACKEND_ALLOC, "internal memory allocation failure" }, + { ERR_BACKEND_PERM, "user login successful, but no permissions to access the desired object" }, + { ERR_BACKEND_MODIFIED, "commit of object update failed because another user has modified the object" }, + { ERR_BACKEND_MOD_DESTROY, "commit of object update failed because another user has deleted the object" }, + { ERR_BACKEND_MISC, "undetermined error" }, + { ERR_QOF_OVERFLOW, "EOVERFLOW - generated by strtol or strtoll. When converting XML strings into numbers, an overflow has been detected. The XML file contains invalid data in a field that is meant to hold a signed long integer or signed long long integer." }, + + /* fileio errors */ + { ERR_FILEIO_FILE_BAD_READ, "read failed or file prematurely truncated" }, + { ERR_FILEIO_FILE_EMPTY, "file exists, is readable, but is empty" }, + { ERR_FILEIO_FILE_LOCKERR, "mangled locks (unspecified error)" }, + { ERR_FILEIO_FILE_NOT_FOUND,"not found / no such file" }, + { ERR_FILEIO_FILE_TOO_OLD, "file version so old we can't read it" }, + { ERR_FILEIO_UNKNOWN_FILE_TYPE,"didn't recognize the file type" }, + { ERR_FILEIO_PARSE_ERROR, "couldn't parse the data in the file" }, + { ERR_FILEIO_BACKUP_ERROR, "couldn't make a backup of the file" }, + { ERR_FILEIO_WRITE_ERROR, "couldn't write to the file" }, + { ERR_FILEIO_READ_ERROR, "Could not open the file for reading." }, + { ERR_FILEIO_NO_ENCODING, "file does not specify encoding" }, + { ERR_FILEIO_FILE_EACCES, "No read access permission for the given file" }, + { ERR_FILEIO_RESERVED_WRITE,"User attempt to write to a directory reserved for internal use by GnuCash" }, + { ERR_FILEIO_FILE_UPGRADE, "file will be upgraded and not be able to be read by prior versions - warn user" }, + + /* network errors */ + { ERR_NETIO_SHORT_READ, "not enough bytes received" }, + { ERR_NETIO_WRONG_CONTENT_TYPE,"wrong kind of server, wrong data served" }, + { ERR_NETIO_NOT_GNCXML, "whatever it is, we can't parse it." }, + + /* database errors */ + { ERR_SQL_MISSING_DATA, "database doesn't contain expected data" }, + { ERR_SQL_DB_TOO_OLD, "database is old and needs upgrading" }, + { ERR_SQL_DB_TOO_NEW, "database is newer, we can't write to it" }, + { ERR_SQL_DB_BUSY, "database is busy, cannot upgrade version" }, + { ERR_SQL_BAD_DBI, "LibDBI has numeric errors" }, + { ERR_SQL_DBI_UNTESTABLE, "could not complete test for LibDBI bug" }, + + /* RPC errors */ + { ERR_RPC_HOST_UNK, "Host unknown" }, + { ERR_RPC_CANT_BIND, "can't bind to address" }, + { ERR_RPC_CANT_ACCEPT, "can't accept connection" }, + { ERR_RPC_NO_CONNECTION, "no connection to server" }, + { ERR_RPC_BAD_VERSION, "RPC Version Mismatch" }, + { ERR_RPC_FAILED, "Operation failed" }, + { ERR_RPC_NOT_ADDED, "object not added" } +}; + QofBackendError qof_backend_get_error (QofBackend* qof_be) { @@ -147,6 +210,18 @@ qof_backend_get_error (QofBackend* qof_be) return ((QofBackend*)qof_be)->get_error(); } +const char* +qof_backend_get_error_string (QofBackendError backend_error) +{ + auto str_iter = qof_backend_error_map.find (backend_error); + if (str_iter == qof_backend_error_map.end()) + { + PERR ("cannot error string for error %d", backend_error); + return nullptr; + } + return str_iter->second; +} + void qof_backend_set_error (QofBackend* qof_be, QofBackendError err) { diff --git a/libgnucash/engine/qofbackend.h b/libgnucash/engine/qofbackend.h index 0b090b0b579..4977f9bea60 100644 --- a/libgnucash/engine/qofbackend.h +++ b/libgnucash/engine/qofbackend.h @@ -131,6 +131,10 @@ typedef struct QofBackend QofBackend; /* The following functions are used in C files. */ /** Get the last backend error. */ QofBackendError qof_backend_get_error (QofBackend*); + +/** Get the last backend error string. */ + const char* qof_backend_get_error_string (QofBackendError); + /** Set the error on the specified QofBackend. */ void qof_backend_set_error (QofBackend*, QofBackendError); From 7add33859ae192fc7e524127d6c8c2a47a9f9f05 Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Sat, 14 Oct 2023 11:29:36 +0800 Subject: [PATCH 2/7] [gnucash-commands.cpp] refactor session loading into load_file - and offer to [R]eadonly [U]nlock or [A]bort where appropriate. --- gnucash/gnucash-commands.cpp | 96 +++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/gnucash/gnucash-commands.cpp b/gnucash/gnucash-commands.cpp index 0ff5896ce1d..37df8807643 100644 --- a/gnucash/gnucash-commands.cpp +++ b/gnucash/gnucash-commands.cpp @@ -99,6 +99,27 @@ report_session_percentage (const char *message, double percent) return; } +static std::string +get_line (const char* prompt) +{ + std::string rv; + std::cout << prompt; + if (!std::getline (std::cin, rv)) + gnc_shutdown_cli (1); + return rv; +} + +static std::string +get_choice (const char* prompt, const std::vector choices) +{ + while (true) + { + auto response = get_line (prompt); + if (std::find (choices.begin(), choices.end(), response) != choices.end()) + return response; + } +} + /* Don't try to use std::string& for the members of the following struct, it * results in the values getting corrupted as it passes through initializing * Scheme when compiled with Clang. @@ -124,6 +145,48 @@ write_report_file (const char *html, const char* file) // ofs destructor will close the file } +static QofSession* +load_file (const std::string& file_to_load, bool open_readwrite) +{ + PINFO ("Loading %s %s", file_to_load.c_str(), open_readwrite ? "(r/w)" : "(readonly)"); + auto session = gnc_get_current_session(); + if (!session) + gnc_shutdown_cli (1); + + auto mode = open_readwrite ? SESSION_NORMAL_OPEN : SESSION_READ_ONLY; + while (true) + { + qof_session_begin (session, file_to_load.c_str(), mode); + auto io_err = qof_session_get_error (session); + switch (io_err) + { + case ERR_BACKEND_NO_ERR: + qof_session_load (session, report_session_percentage); + return session; + case ERR_BACKEND_LOCKED: + { + // Translators: [R] [U] and [A] are responses and must not be translated. + auto response = get_choice (_("File Locked. Open [R]eadonly, [U]nlock or [A]bort?"), + {"R","r","U","u","A","a"}); + if (response == "R" || response == "r") + mode = SESSION_READ_ONLY; + else if (response == "U" || response == "u") + mode = SESSION_BREAK_LOCK; + else if (response == "A" || response == "a") + gnc_shutdown_cli (1); + break; + } + case ERR_BACKEND_READONLY: + std::cerr << _("File is readonly. Cannot open read-write") << std::endl; + mode = SESSION_READ_ONLY; + break; + default: + std::cerr << _("Unknown error. Abort.") << std::endl; + scm_cleanup_and_exit_with_failure (session); + } + } +} + static void scm_run_report (void *data, [[maybe_unused]] int argc, [[maybe_unused]] char **argv) @@ -160,17 +223,7 @@ scm_run_report (void *data, PINFO ("Loading datafile %s...\n", datafile); - auto session = gnc_get_current_session (); - if (!session) - scm_cleanup_and_exit_with_failure (session); - - qof_session_begin (session, datafile, SESSION_READ_ONLY); - if (qof_session_get_error (session) != ERR_BACKEND_NO_ERR) - scm_cleanup_and_exit_with_failure (session); - - qof_session_load (session, report_session_percentage); - if (qof_session_get_error (session) != ERR_BACKEND_NO_ERR) - scm_cleanup_and_exit_with_failure (session); + auto session = load_file (args->file_to_load, false); if (!args->export_type.empty()) { @@ -273,14 +326,7 @@ scm_report_show (void *data, { auto datafile = args->file_to_load.c_str(); PINFO ("Loading datafile %s...\n", datafile); - - auto session = gnc_get_current_session (); - if (session) - { - qof_session_begin (session, datafile, SESSION_READ_ONLY); - if (qof_session_get_error (session) == ERR_BACKEND_NO_ERR) - qof_session_load (session, report_session_percentage); - } + [[maybe_unused]] auto session = load_file (args->file_to_load, false); } scm_call_2 (scm_c_eval_string ("gnc:cmdline-report-show"), @@ -345,17 +391,7 @@ Gnucash::add_quotes (const bo_str& uri) gnc_prefs_init (); qof_event_suspend(); - auto session = gnc_get_current_session(); - if (!session) - return 1; - - qof_session_begin(session, uri->c_str(), SESSION_NORMAL_OPEN); - if (qof_session_get_error(session) != ERR_BACKEND_NO_ERR) - return cleanup_and_exit_with_failure (session); - - qof_session_load(session, NULL); - if (qof_session_get_error(session) != ERR_BACKEND_NO_ERR) - return cleanup_and_exit_with_failure (session); + auto session = load_file (*uri, true); try { From c4e8d72ee52c3325a6b86e9a26c9df38e76463da Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Sat, 14 Oct 2023 11:29:46 +0800 Subject: [PATCH 3/7] [gnucash-commands.cpp] scripting & REPL for guile & python Syntax: gnucash-cli [datafile.gnucash [--readwrite]] [--interactive] [--script script.py or script.scm] [--language python|guile] 1. --script or --interactive will launch scripting 2. a datafile will trigger loading, will attempt r/w if requested 3. --language python will choose python 4. --interactive will run the Guile or Python commandline with access to gnucash modules --- gnucash/CMakeLists.txt | 8 +- gnucash/gnucash-cli.cpp | 41 +++++++- gnucash/gnucash-commands.cpp | 195 ++++++++++++++++++++++++++++++++++- gnucash/gnucash-commands.hpp | 6 ++ gnucash/gnucash-core-app.cpp | 2 +- 5 files changed, 245 insertions(+), 7 deletions(-) diff --git a/gnucash/CMakeLists.txt b/gnucash/CMakeLists.txt index a9461979b3f..362d835a6ee 100644 --- a/gnucash/CMakeLists.txt +++ b/gnucash/CMakeLists.txt @@ -111,6 +111,7 @@ target_link_libraries (gnucash gnc-bi-import gnc-customer-import gnc-report PkgConfig::GTK3 ${GUILE_LDFLAGS} PkgConfig::GLIB2 ${Boost_LIBRARIES} + ${Python3_LIBRARIES} ) set(gnucash_cli_SOURCES @@ -137,15 +138,20 @@ endif() add_dependencies (gnucash-cli gnucash) -target_compile_definitions(gnucash-cli PRIVATE -DG_LOG_DOMAIN=\"gnc.bin\") +target_compile_definitions(gnucash-cli PRIVATE + -DG_LOG_DOMAIN=\"gnc.bin\" + $<$:HAVE_PYTHON_H>) target_link_libraries (gnucash-cli gnc-app-utils gnc-engine gnc-core-utils gnucash-guile gnc-report ${GUILE_LDFLAGS} PkgConfig::GLIB2 ${Boost_LIBRARIES} + ${Python3_LIBRARIES} ) +target_include_directories (gnucash-cli PRIVATE ${Python3_INCLUDE_DIRS}) + if (BUILDING_FROM_VCS) target_compile_definitions(gnucash PRIVATE -DGNC_VCS=\"git\") target_compile_definitions(gnucash-cli PRIVATE -DGNC_VCS=\"git\") diff --git a/gnucash/gnucash-cli.cpp b/gnucash/gnucash-cli.cpp index 23a8475fc9d..e757ef50fd5 100644 --- a/gnucash/gnucash-cli.cpp +++ b/gnucash/gnucash-cli.cpp @@ -62,6 +62,12 @@ namespace Gnucash { boost::optional m_namespace; bool m_verbose = false; + boost::optional m_script; + std::vector m_script_args; + std::string m_language; + bool m_interactive; + bool m_open_readwrite; + boost::optional m_report_cmd; boost::optional m_report_name; boost::optional m_export_type; @@ -107,6 +113,17 @@ Gnucash::GnucashCli::configure_program_options (void) m_opt_desc_display->add (quotes_options); m_opt_desc_all.add (quotes_options); + bpo::options_description cli_options(_("Scripting and/or Interactive Session Options")); + cli_options.add_options() + ("script,S", bpo::value (&m_script), _("Script to run")) + ("script-args", bpo::value (&m_script_args), _("Script arguments")) + ("interactive,I", bpo::bool_switch (&m_interactive), _("Interactive session")) + ("language,L", bpo::value (&m_language)->default_value("guile"), _("Specify language for script or interactive session; guile (default) or python")) + ("readwrite,W", bpo::bool_switch (&m_open_readwrite), _("Open datafile read-write for script and/or interactive session")); + m_pos_opt_desc.add("script-args", -1); + m_opt_desc_display->add (cli_options); + m_opt_desc_all.add (cli_options); + bpo::options_description report_options(_("Report Generation Options")); report_options.add_options() ("report,R", bpo::value (&m_report_cmd), @@ -127,10 +144,32 @@ may be specified to describe some saved options.\n" } int -Gnucash::GnucashCli::start ([[maybe_unused]] int argc, [[maybe_unused]] char **argv) +Gnucash::GnucashCli::start (int argc, char **argv) { Gnucash::CoreApp::start(); + if (m_interactive || m_script) + { + std::vector newArgv = + { argc ? argv[0] : "", m_file_to_load ? m_file_to_load->c_str() : ""}; + std::transform (m_script_args.begin(), m_script_args.end(), std::back_inserter(newArgv), + [](const std::string& s) { return s.c_str(); }); + // note the vector is valid as long as script_args's strings are not damaged! + + std::cout << "\n\nScript args:"; + for (const auto& arg : newArgv) + std::cout << ' ' << arg; + std::cout << '\n'; + std::cout << "File to load: " << (m_file_to_load ? *m_file_to_load : "(null)") << std::endl; + std::cout << "Language: " << m_language << std::endl; + std::cout << "Script: " << (m_script ? *m_script : "(null)") << std::endl; + std::cout << "Readwrite: " << (m_open_readwrite ? 'Y' : 'N') << std::endl; + std::cout << "Interactive: " << (m_interactive ? 'Y' : 'N') << "\n\n" << std::endl; + + return Gnucash::run_scripting (newArgv, m_file_to_load, m_language, m_script, + m_open_readwrite, m_interactive); + } + if (!m_quotes_cmd.empty()) { if (m_quotes_cmd.front() == "info") diff --git a/gnucash/gnucash-commands.cpp b/gnucash/gnucash-commands.cpp index 37df8807643..95717acd5f4 100644 --- a/gnucash/gnucash-commands.cpp +++ b/gnucash/gnucash-commands.cpp @@ -31,6 +31,8 @@ #include "gnucash-commands.hpp" #include "gnucash-core-app.hpp" +#include "gnc-ui-util.h" +#include "gnc-path.h" #include #include @@ -40,12 +42,17 @@ #include #include +#include #include #include #include #include #include +#ifdef HAVE_PYTHON_H +#include +#endif + namespace bl = boost::locale; static std::string empty_string{}; @@ -112,12 +119,48 @@ get_line (const char* prompt) static std::string get_choice (const char* prompt, const std::vector choices) { - while (true) + std::string response; + do + { + response = get_line (prompt); + } + while (std::none_of (choices.begin(), choices.end(), + [&response](auto& choice) { return choice == response; } )); + return response; +} + +struct scripting_args +{ + const boost::optional& script; + bool interactive; +}; + +// when guile or python script/interactive session succeed +static void +cleanup_and_exit_with_save () +{ + if (gnc_current_session_exist()) { - auto response = get_line (prompt); - if (std::find (choices.begin(), choices.end(), response) != choices.end()) - return response; + auto session = gnc_get_current_session(); + auto book = gnc_get_current_book(); + auto is_yes = [] (const char* prompt) + { + auto s = get_choice (prompt, {"Y","y","N","n"}); + return s == "Y" || s == "y"; + }; + + std::cerr << _("Warning: session was not cleared.") << std::endl; + + if (!qof_book_session_not_saved (book)) + std::cerr << _("Please don't forget to clear session before shutdown.") << std::endl; + else if (qof_book_is_readonly (book)) + std::cerr << _("Book is readonly. Unsaved changes will be lost.") << std::endl; + else if (is_yes (_("There are unsaved changes. Save before clearing [YN]?"))) + qof_session_save (session, report_session_percentage); + + gnc_clear_current_session (); } + gnc_shutdown_cli (0); } /* Don't try to use std::string& for the members of the following struct, it @@ -470,3 +513,147 @@ Gnucash::report_list (void) scm_boot_guile (0, nullptr, scm_report_list, NULL); return 0; } + +// scripting code follows: + +static void +run_guile_cli (void *data, [[maybe_unused]] int argc, [[maybe_unused]] char **argv) +{ + auto args = static_cast(data); + if (args->script) + { + PINFO ("Running script from %s... ", args->script->c_str()); + scm_c_primitive_load (args->script->c_str()); + } + if (args->interactive) + { + std::cout << _("Welcome to Gnucash Interactive Guile Session") << std::endl; + std::vector modules = + { "gnucash core-utils", "gnucash engine", "gnucash app-utils", "gnucash report", + "system repl repl", "ice-9 readline" }; + auto show_and_load = [](const auto& mod) + { + std::cout << bl::format ("(use-modules ({1}))") % mod << std::endl; + scm_c_use_module (mod); + }; + std::for_each (modules.begin(), modules.end(), show_and_load); + scm_c_eval_string ("(activate-readline)"); + scm_c_eval_string ("(start-repl)"); + } + cleanup_and_exit_with_save (); +} + + +#ifdef HAVE_PYTHON_H +static void +python_cleanup_and_exit (PyConfig& config, PyStatus& status) +{ + if (qof_book_session_not_saved (gnc_get_current_book())) + std::cerr << _("Book is readonly. Unsaved changes will be lost.") << std::endl; + gnc_clear_current_session (); + + PyConfig_Clear(&config); + if (status.err_msg && *status.err_msg) + std::cerr << bl::format (_("Python Config failed with error {1}")) % status.err_msg + << std::endl; + gnc_shutdown_cli (status.exitcode); +} + +static void +run_python_cli (int argc, char **argv, scripting_args* args) +{ + PyConfig config; + PyConfig_InitPythonConfig(&config); + + PyStatus status = PyConfig_SetBytesArgv(&config, argc, argv); + if (PyStatus_Exception(status)) + python_cleanup_and_exit (config, status); + + status = Py_InitializeFromConfig(&config); + if (PyStatus_Exception(status)) + python_cleanup_and_exit (config, status); + + PyConfig_Clear(&config); + + if (args->script) + { + auto script_filename = args->script->c_str(); + PINFO ("Running python script %s...", script_filename); + auto fp = fopen (script_filename, "rb"); + if (!fp) + { + std::cerr << bl::format (_("Unable to load Python script {1}")) % script_filename + << std::endl; + python_cleanup_and_exit (config, status); + } + else if (PyRun_SimpleFileEx (fp, script_filename, 1) != 0) + { + std::cerr << bl::format (_("Python script {1} execution failed.")) % script_filename + << std::endl; + python_cleanup_and_exit (config, status); + } + } + if (args->interactive) + { + std::cout << _("Welcome to Gnucash Interactive Python Session") << std::endl; + PyRun_InteractiveLoop (stdin, "foo"); + } + Py_Finalize(); + cleanup_and_exit_with_save (); +} +#endif + +int +Gnucash::run_scripting (std::vector newArgv, + const bo_str& file_to_load, + std::string& language, + const bo_str& script, + bool open_readwrite, + bool interactive) +{ + std::vector errors; + static const std::vector languages = { "guile", "python" }; + + if (open_readwrite && !file_to_load) + errors.push_back (_ ("--readwrite: missing datafile!")); + + if (script && (!boost::filesystem::is_regular_file (*script))) + errors.push_back ((bl::format (_("--script: {1} is not a file")) % *script).str()); + + if (std::none_of (languages.begin(), languages.end(), + [&language](auto& lang){ return language == lang; })) + errors.push_back (_ ("--language: must be 'python' or 'guile'")); +#ifndef HAVE_PYTHON_H + else if (language == "python") + errors.push_back (_("--language: python wasn't compiled in this build")); +#endif + + if (!errors.empty()) + { + std::cerr << _("Errors parsing arguments:") << std::endl; + auto to_console = [](const auto& str){ std::cerr << str << std::endl; }; + std::for_each (errors.begin(), errors.end(), to_console); + gnc_shutdown_cli (1); + } + + gnc_prefs_init (); + gnc_ui_util_init(); + if (file_to_load && boost::filesystem::is_regular_file (*file_to_load)) + [[maybe_unused]] auto session = load_file (*file_to_load, open_readwrite); + + scripting_args args { script, interactive }; + if (language == "guile") + { + scm_boot_guile (newArgv.size(), (char**)newArgv.data(), run_guile_cli, &args); + return 0; // never reached... + } +#ifdef HAVE_PYTHON_H + else if (language == "python") + { + run_python_cli (newArgv.size(), (char**)newArgv.data(), &args); + return 0; // never reached... + } +#endif + + return 0; // never reached +} diff --git a/gnucash/gnucash-commands.hpp b/gnucash/gnucash-commands.hpp index 5a32e9056b5..55671dcaa55 100644 --- a/gnucash/gnucash-commands.hpp +++ b/gnucash/gnucash-commands.hpp @@ -46,5 +46,11 @@ namespace Gnucash { int report_list (void); int report_show (const bo_str& file_to_load, const bo_str& run_report); + int run_scripting (std::vector newArgv, + const bo_str& m_file_to_load, + std::string& m_language, + const bo_str& m_script, + bool m_open_readwrite, + bool m_interactive); } #endif diff --git a/gnucash/gnucash-core-app.cpp b/gnucash/gnucash-core-app.cpp index 08ae1fc9cb7..cdc2c1ce2f8 100644 --- a/gnucash/gnucash-core-app.cpp +++ b/gnucash/gnucash-core-app.cpp @@ -294,7 +294,7 @@ Gnucash::CoreApp::add_common_program_options (void) ("input-file", bpo::value (&m_file_to_load), _("[datafile]")); - m_pos_opt_desc.add("input-file", -1); + m_pos_opt_desc.add("input-file", 1); m_opt_desc_all.add (common_options); m_opt_desc_all.add (hidden_options); From cf2a026078655dd773c3bf71875f7b321edb0310 Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Mon, 23 Oct 2023 15:51:38 +0800 Subject: [PATCH 4/7] [gnucash-commands.cpp] colorize cpp get_line on all platforms --- gnucash/gnucash-commands.cpp | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/gnucash/gnucash-commands.cpp b/gnucash/gnucash-commands.cpp index 95717acd5f4..19fda974d76 100644 --- a/gnucash/gnucash-commands.cpp +++ b/gnucash/gnucash-commands.cpp @@ -60,6 +60,38 @@ static std::string empty_string{}; /* This static indicates the debugging module that this .o belongs to. */ static QofLogModule log_module = GNC_MOD_GUI; +#ifdef _WIN32 +struct ConsoleStruct +{ +public: + bool has_ansi () { return m_has_ansi; }; +private: + HANDLE m_stdoutHandle = INVALID_HANDLE_VALUE; + DWORD m_outModeInit = 0; + bool m_has_ansi = false; + ConsoleStruct () : m_stdoutHandle {GetStdHandle(STD_OUTPUT_HANDLE)} + { + if (m_stdoutHandle != INVALID_HANDLE_VALUE && GetConsoleMode(m_stdoutHandle, &m_outModeInit)) + { + SetConsoleMode (m_stdoutHandle, m_outModeInit | ENABLE_VIRTUAL_TERMINAL_PROCESSING); + m_has_ansi = true; + } + } + ~ConsoleStruct () + { + if (m_stdoutHandle == INVALID_HANDLE_VALUE) + return; + printf ("\x1b[0m"); + SetConsoleMode (m_stdoutHandle, m_outModeInit); + } +} console_ansi; +#else +struct ConsoleStruct +{ + bool has_ansi () { return true; }; +} console_ansi; +#endif + static int cleanup_and_exit_with_failure (QofSession *session) { @@ -110,7 +142,8 @@ static std::string get_line (const char* prompt) { std::string rv; - std::cout << prompt; + std::cout << (console_ansi.has_ansi() ? "\x1b[1;33m" : "") << prompt + << (console_ansi.has_ansi() ? "\x1b[m" : ""); if (!std::getline (std::cin, rv)) gnc_shutdown_cli (1); return rv; From 192205c2a0e911ac40d557c2567af1015bb6ea6b Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Sat, 21 Oct 2023 14:39:35 +0800 Subject: [PATCH 5/7] [book-to-hledger.scm] simple script to export book to hledger format gnucash-cli book.gnucash --script book-to-hledger.scm > ~/.hledger.journal hledger bs hledger bs Balance Sheet 2023-10-20 || 2023-10-20 =====================++============ Assets || ---------------------++------------ Assets:Current:Bank || -168.3 USD ---------------------++------------ || -168.3 USD =====================++============ Liabilities || ---------------------++------------ ---------------------++------------ || =====================++============ Net: || -168.3 USD --- doc/examples/book-to-hledger.scm | 62 ++++++++++++++++++++++++++++++++ po/POTFILES.skip | 3 ++ 2 files changed, 65 insertions(+) create mode 100644 doc/examples/book-to-hledger.scm diff --git a/doc/examples/book-to-hledger.scm b/doc/examples/book-to-hledger.scm new file mode 100644 index 00000000000..a67e2263ee1 --- /dev/null +++ b/doc/examples/book-to-hledger.scm @@ -0,0 +1,62 @@ +;; this file is meant to be run via the gnucash-cli interface: --script simple-book-add-txn.scm +;; +;; gnucash-cli book.gnucash --script simple-book-add-txn.scm +;; + +(use-modules (gnucash core-utils)) +(use-modules (gnucash engine)) +(use-modules (gnucash app-utils)) +(use-modules (gnucash report)) +(use-modules (ice-9 match)) + +(define iso-date (qof-date-format-get-string QOF-DATE-FORMAT-ISO)) +(define book (gnc-get-current-book)) +(define root (gnc-get-current-root-account)) +(define query (qof-query-create-for-splits)) +(qof-query-set-book query (gnc-get-current-book)) +(xaccQueryAddAccountMatch query (gnc-account-get-descendants root) QOF-GUID-MATCH-ANY QOF-QUERY-AND) +(qof-query-set-sort-order + query + (list SPLIT-TRANS TRANS-DATE-POSTED) + '() + (list QUERY-DEFAULT-SORT)) + +(define (dump-transaction trans) + (format #t "~a ~a\n" + (gnc-print-time64 (xaccTransGetDate trans) iso-date) + (xaccTransGetDescription trans)) + (define (split->account s) + (gnc-account-get-full-name (xaccSplitGetAccount s))) + (define (split->amount s) + (format #f "~a ~a" + (exact->inexact (xaccSplitGetAmount s)) + (gnc-commodity-get-mnemonic (xaccAccountGetCommodity (xaccSplitGetAccount s))))) + (define max-width + (let lp ((splits (xaccTransGetSplitList trans)) (maximum 0)) + (match splits + (() (+ maximum 2)) + ((s . rest) + (lp rest (max maximum (+ (string-length (split->account s)) + (string-length (split->amount s))))))))) + (for-each + (lambda (s) + (define txn (xaccSplitGetParent s)) + (define acc-name (split->account s)) + (define amt-str (split->amount s)) + (format #t " ~a~a~a\n" + acc-name + (make-string (- max-width (string-length acc-name) (string-length amt-str)) #\space) + amt-str)) + (xaccTransGetSplitList trans))) + +(define split-has-no-account? (compose null? xaccSplitGetAccount)) + +(let lp ((splits (xaccQueryGetSplitsUniqueTrans query))) + (newline) + (match splits + (() #f) + (((? split-has-no-account?) . rest) (lp rest)) + ((split . rest) (dump-transaction (xaccSplitGetParent split)) (lp rest)))) + +(qof-query-destroy query) +(gnc-clear-current-session) diff --git a/po/POTFILES.skip b/po/POTFILES.skip index a769815d880..e656249ddcd 100644 --- a/po/POTFILES.skip +++ b/po/POTFILES.skip @@ -15,3 +15,6 @@ libgnucash/engine/iso-4217-currencies.c # This file containing @PROJECT_NAME@ shouldn't be translated. gnucash/gschemas/org.gnucash.GnuCash.deprecated.gschema.xml.in + +# These files are example scripts for gnucash-cli +doc/examples/book-to-hledger.scm From 6d9e6cf7d2ed794decc1eadd3f4288c3a9140a01 Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Wed, 11 Oct 2023 07:06:49 +0800 Subject: [PATCH 6/7] [simple-book-add-txn.scm] simple script to add txn into account --- doc/examples/simple-book-add-txn.scm | 139 +++++++++++++++++++++++++++ po/POTFILES.skip | 1 + 2 files changed, 140 insertions(+) create mode 100644 doc/examples/simple-book-add-txn.scm diff --git a/doc/examples/simple-book-add-txn.scm b/doc/examples/simple-book-add-txn.scm new file mode 100644 index 00000000000..168d3a6f778 --- /dev/null +++ b/doc/examples/simple-book-add-txn.scm @@ -0,0 +1,139 @@ +;; this file is meant to be run via the gnucash-cli interface: --script simple-book-add-txn.scm +;; +;; gnucash-cli book.gnucash --script simple-book-add-txn.scm +;; +;; the book will be considered "valid" if it has a basic hierarchy such as the following +;; Assets +;; |-Current +;; | |- Bank +;; Expenses +;; |-Govt +;; | |- Taxes +;; |-Personal +;; |-Medical + +(use-modules (gnucash core-utils)) +(use-modules (gnucash engine)) +(use-modules (gnucash app-utils)) +(use-modules (gnucash report)) +(use-modules (ice-9 rdelim)) +(use-modules (ice-9 match)) + +(define (get-line prompt) + (format #t "\x1b[1;33m~a:\x1b[m " prompt) + (let ((rv (read-line))) + (if (eof-object? rv) "" rv))) + +(define (get-amount prompt) + (let ((amount (gnc-numeric-from-string (get-line prompt)))) + (if (number? amount) + amount + (get-amount prompt)))) + +(define (get-item-from-list lst elt->string prompt) + (define (get-amount-line) (get-amount prompt)) + (let lp ((idx 1) (lst lst)) + (unless (null? lst) + (format #t "~a. ~a\n" idx (elt->string (car lst))) + (lp (1+ idx) (cdr lst)))) + (let lp ((idx (get-amount-line))) + (cond + ((and (integer? idx) (positive? idx)) + (let lp1 ((idx (1- idx)) (lst lst)) + (cond + ((null? lst) (lp (get-amount-line))) + ((zero? idx) (car lst)) + (else (lp1 (1- idx) (cdr lst)))))) + (else (lp (get-amount-line)))))) + +(define (get-account prompt parent) + (define descendants (gnc-account-get-descendants-sorted parent)) + (get-item-from-list descendants gnc-account-get-full-name "Select account by index")) + +(define (get-binary-response prompt) + (match (get-line prompt) + ((or "Y" "y") #t) + ((or "N" "n") #f) + (else (get-binary-response prompt)))) + +(define (add-to-transaction book txn account amount memo) + (let ((split (xaccMallocSplit book))) + (xaccSplitSetAccount split account) + (xaccSplitSetAmount split amount) + (xaccSplitSetValue split amount) + (xaccSplitSetMemo split memo) + (xaccSplitSetParent split txn))) + +(define (quit-program exitlevel) + (gnc-clear-current-session) + (exit exitlevel)) + +(define (get-new-uri session) + (define filepath (get-line "please input correct path, or leave blank to abort")) + (gnc-clear-current-session) + (cond + ((string-null? filepath) (quit-program 1)) + ((qof-session-load-quiet filepath SESSION-NORMAL-OPEN) #f) ;success + (else (get-new-uri session)))) + +(define session (gnc-get-current-session)) +(define root (gnc-get-current-root-account)) + +(let check-book-loop () + (cond + ((or (null? (gnc-account-lookup-by-full-name root "Assets:Current:Bank")) + (null? (gnc-account-lookup-by-full-name root "Expenses")) + (null? (gnc-account-lookup-by-full-name root "Expenses:Govt:Taxes"))) + (display "\n\n\nWARNING: It doesn't seem the correct book is loaded.\n") + (get-new-uri session) + (check-book-loop)))) + +(define book (gnc-get-current-book)) +(define acc-BANK (gnc-account-lookup-by-full-name root "Assets:Current:Bank")) +(define acc-EXP (gnc-account-lookup-by-full-name root "Expenses")) +(define acc-EXP-TAX (gnc-account-lookup-by-full-name root "Expenses:Govt:Taxes")) +(define acc-EXP-LEAF (get-account "Expense leaf account" acc-EXP)) + +(define (accounts-action action-fn) + (action-fn acc-BANK) + (action-fn acc-EXP-LEAF) + (action-fn acc-EXP-TAX)) + +(define description (get-line "Description")) + +(let lp () + (define txn (xaccMallocTransaction book)) + (define net-amount (get-amount "Amount, without tax")) + (define tax-amount (* net-amount 1/10)) + (define total-amount (+ tax-amount net-amount)) + + (xaccTransBeginEdit txn) + (xaccTransSetCurrency txn (xaccAccountGetCommodity acc-BANK)) + (xaccTransSetDatePostedSecsNormalized txn (current-time)) + (xaccTransSetDescription txn description) + (add-to-transaction book txn acc-BANK (- total-amount) "from bank") + (add-to-transaction book txn acc-EXP-LEAF net-amount "expense net") + (add-to-transaction book txn acc-EXP-TAX tax-amount "tax paid") + (newline) + (gnc:dump-transaction txn) + + (cond + ((not (xaccTransIsBalanced txn)) + (display "WARNING: transaction is not balanced. Try again.\n") + (xaccTransRollbackEdit txn) + (xaccTransDestroy txn) + (lp)) + ((get-binary-response "Please confirm transaction [YN]") + (accounts-action xaccAccountBeginEdit) + (xaccTransCommitEdit txn) + (accounts-action xaccAccountCommitEdit)) + (else + (xaccTransRollbackEdit txn) + (xaccTransDestroy txn)))) + +;; (gnc:dump-book) +(when (qof-book-session-not-saved book) + (display "Saving book...\n") + (qof-session-save-quiet)) + +(quit-program 0) diff --git a/po/POTFILES.skip b/po/POTFILES.skip index e656249ddcd..22b8e34b570 100644 --- a/po/POTFILES.skip +++ b/po/POTFILES.skip @@ -18,3 +18,4 @@ gnucash/gschemas/org.gnucash.GnuCash.deprecated.gschema.xml.in # These files are example scripts for gnucash-cli doc/examples/book-to-hledger.scm +doc/examples/simple-book-add-txn.scm From de536c9591bc39e35e1e468a79442f7cb1723e80 Mon Sep 17 00:00:00 2001 From: Christopher Lam Date: Tue, 31 Oct 2023 20:09:47 +0800 Subject: [PATCH 7/7] uri supports file: mysql: and postgres: --- gnucash/gnucash-commands.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/gnucash/gnucash-commands.cpp b/gnucash/gnucash-commands.cpp index 19fda974d76..d70609a5771 100644 --- a/gnucash/gnucash-commands.cpp +++ b/gnucash/gnucash-commands.cpp @@ -636,6 +636,29 @@ run_python_cli (int argc, char **argv, scripting_args* args) } #endif +static const std::vector valid_schemes = { "file", "mysql", "postgres" }; + +static bool +is_valid_uri (const bo_str& uri) +{ + if (!uri) + return false; + + if (boost::filesystem::is_regular_file (*uri)) + return true; + + auto scheme = g_uri_parse_scheme (uri->c_str()); + + if (!scheme) + return false; + + auto rv = std::any_of (valid_schemes.begin(), valid_schemes.end(), + [&scheme](const char* str) { return !g_strcmp0(str, scheme); }); + + g_free (scheme); + return rv; +} + int Gnucash::run_scripting (std::vector newArgv, const bo_str& file_to_load, @@ -671,7 +694,7 @@ Gnucash::run_scripting (std::vector newArgv, gnc_prefs_init (); gnc_ui_util_init(); - if (file_to_load && boost::filesystem::is_regular_file (*file_to_load)) + if (is_valid_uri (file_to_load)) [[maybe_unused]] auto session = load_file (*file_to_load, open_readwrite); scripting_args args { script, interactive };