From 4cfbd259d1a6fbe7592b8975eed399b46082edc1 Mon Sep 17 00:00:00 2001 From: Aldrik Ramaekers Date: Sat, 4 Oct 2025 09:25:36 +0200 Subject: import ui --- src/ai_providers/openAI.cpp | 20 +++--- src/ai_service.cpp | 95 --------------------------- src/import_service.cpp | 152 ++++++++++++++++++++++++++++++++++++++++++++ src/locales/en.cpp | 11 ++++ src/main.cpp | 2 +- src/ui/ui_expenses.cpp | 61 ++++++++++++++++-- 6 files changed, 232 insertions(+), 109 deletions(-) delete mode 100644 src/ai_service.cpp create mode 100644 src/import_service.cpp (limited to 'src') diff --git a/src/ai_providers/openAI.cpp b/src/ai_providers/openAI.cpp index e005a80..7675e8b 100644 --- a/src/ai_providers/openAI.cpp +++ b/src/ai_providers/openAI.cpp @@ -24,15 +24,13 @@ #include "httplib.h" #include "strops.hpp" #include "log.hpp" -#include "ai_service.hpp" +#include "import_service.hpp" static bool _openAI_query_with_file(char* query, size_t query_length, char* file_id, char** response) { - //#define TESTING_IMPORT - - #ifndef TESTING_IMPORT + #if 0 const char *api_key = administration_get_ai_service().api_key_public; - + httplib::SSLClient cli("api.openai.com", 443); //cli.enable_server_certificate_verification(false); @@ -57,16 +55,17 @@ static bool _openAI_query_with_file(char* query, size_t query_length, char* file } char* response_body = (char*)res->body.c_str(); - #else - char* response_body = "{\n \"id\": \"resp_68d9482030fc8196930b43b6b28feeb104e98afee829eee0\",\n \"object\": \"response\",\n \"created_at\": 1759070240,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"error\": null,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-5-2025-08-07\",\n \"output\": [\n {\n \"id\": \"rs_68d94821d1f0819694533a6ed7ed6b2904e98afee829eee0\",\n \"type\": \"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_68d948a09e0c819696782e09c6b7626104e98afee829eee0\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"\\n urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0\\n urn:fdc:peppol.eu:2017:poacc:billing:01:1.0\\n 586928\\n 2025-03-17\\n 2025-03-24\\n 380\\n EUR\\n \\n 699607\\n \\n \\n AR385893\\n Jouw bestelling : 420675-WWW.SCHROEVEN-EXPRESS.NL-14.03.25-18:31\\n \\n \\n 420675-WWW.SCHROEVEN-EXPRESS.NL-14.03.25-18:31\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n R.C le mans B 302 494 224\\n \\n \\n Visserie-service\\n \\n \\n Z.A Nord\\n \\n Parce sur Sarthe\\n 72300\\n \\n \\n FR\\n \\n \\n \\n FR57 302 494 224\\n \\n VAT\\n \\n \\n \\n Visserie Service SAS\\n \\n \\n AMELIE L\\n 02.43.62.09.08\\n klantenservice@schroeven-express.nl\\n \\n \\n \\n \\n \\n \\n \\n cl585187\\n \\n \\n ALDRIK RAMAEKERS\\n \\n \\n KEERDERSTRAAT 81\\n \\n MAASTRICHT\\n 6226X\\n \\n \\n NL\\n \\n \\n \\n \\n \\n VAT\\n \\n \\n \\n ALDRIK RAMAEKERS\\n \\n \\n A RAMAEKERS\\n 31618260377\\n aldrikboy@gmail.com\\n \\n \\n \\n \\n 2025-03-17\\n \\n \\n KEERDERSTRAAT 81\\n \\n MAASTRICHT\\n 6226X\\n \\n \\n NL\\n \\n \\n \\n \\n \\n ALDRIK RAMAEKERS\\n \\n \\n \\n \\n \\n 586928\\n \\n FR76 1790 6001 1272 5017 0700 137\\n Visserie Service SAS\\n \\n \\n AGRIFRPP879\\n \\n \\n \\n \\n \\n \\n \\n \\n 2.59\\n \\n 12.36\\n 2.59\\n \\n \\n 21\\n \\n VAT\\n \\n \\n \\n \\n \\n 6.95\\n 12.36\\n 14.95\\n 14.95\\n \\n \\n 1\\n 500\\n 6.95\\n \\n false\\n Discount\\n \\n \\n \\n \\n \\n Metalen schroeven RVS A2 gefreesde kop Pozi N\\u00b01 M2X4 DIN 965 ISO 7046, VS0109, VS0110\\n \\n Internal Tax Rate ID\\n \\n \\n \\n \\n 21\\n \\n VAT\\n \\n \\n \\n \\n 1.39\\n \\n \\n\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n"; - #endif - *response = (char*)malloc(100000); memset(*response, 0, 100000); strncpy(*response, response_body, 100000); strops_get_json_value(*response, "text", *response, 100000); *response = strops_unprep_str_from_json(*response); + #else + *response = (char*)malloc(100000); + memset(*response, 0, 100000); + strops_copy(*response, " urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 492043632 2024-09-01 2024-09-01 380 USD Final invoice do:team:67840ecb-44e2-472e-bc45-801bd4e1f1fe DigitalOcean LLC 101 Avenue of the Americas 2nd Floor New York 10013 NY US EU528002224 VAT DigitalOcean LLC My Team Keerderstraat 81 Maastricht 6226 XW LI NL VAT aldrikboy@gmail.com 492043632 3.49 15.60 3.28 VAT 1.00 0.21 VAT 16.60 16.60 20.09 20.09 1 16.60 false Discount Product Usage Charges Internal Tax Rate ID VAT ", 100000); + #endif return 1; } @@ -98,6 +97,7 @@ static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_l httplib::Result res = cli.Post("/v1/uploads", headers, body, "application/json"); if (!res || res->status != 200) { log_error("ERROR Failed to create upload."); + log_error(res->body.c_str()); fclose(orig_file); return 0; } @@ -133,6 +133,7 @@ static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_l if (!part_res || part_res->status != 200) { log_error("Failed to upload part %d.", part_number); + log_error(part_res->body.c_str()); free(buffer); fclose(orig_file); return 0; @@ -161,6 +162,7 @@ static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_l if (!complete_res || complete_res->status != 200) { log_error("ERROR Failed to complete upload."); + log_error(complete_res->body.c_str()); return 0; } diff --git a/src/ai_service.cpp b/src/ai_service.cpp deleted file mode 100644 index 82d81d6..0000000 --- a/src/ai_service.cpp +++ /dev/null @@ -1,95 +0,0 @@ -/* -* Copyright (c) 2025 Aldrik Ramaekers -* -* Permission to use, copy, modify, and/or distribute this software for any -* purpose with or without fee is hereby granted, provided that the above -* copyright notice and this permission notice appear in all copies. -* -* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ - -#define _CRT_SECURE_NO_WARNINGS - -#include -#include -#include - -#define CPPHTTPLIB_OPENSSL_SUPPORT -#include "httplib.h" -#include "log.hpp" -#include "ai_service.hpp" -#include "strops.hpp" -#include "administration_reader.hpp" - -ai_provider_impl _ai_get_impl() -{ - ai_provider provider = administration_get_ai_service().provider; - - switch(provider) - { - case AI_PROVIDER_OPENAI: return _chatgpt_api_provider; - default: assert(0); break; - } - - return ai_provider_impl {0}; -} - -extern const char* peppol_invoice_template; -extern const char* peppol_invoice_line_template; - -ai_request* ai_document_to_invoice(char* file_path) -{ - ai_provider_impl impl = _ai_get_impl(); - - char file_id[100]; - if (!impl.upload_file(file_path, file_id, 100)) { - return 0; - } - - size_t query_buffer_len = 50000; - char* template_buffer = (char*)malloc(query_buffer_len); - memset(template_buffer, 0, query_buffer_len); - - strncpy(template_buffer, peppol_invoice_template, query_buffer_len); - strops_replace(template_buffer, 50000, "{{INVOICE_LINE_LIST}}", peppol_invoice_line_template); - - char* ai_query = - "\n\nI have provided a file containing an invoice. Fill in the above Peppol 3.0 template with the information from the invoice." - "Do not add any fields to the template. If you can't find data for a given field, leave it empty. Do not make up any information." - "Only return the filled out template in valid XML format. Nothing else.\n"; - - size_t query_len = strlen(template_buffer); - strncpy(template_buffer + query_len, ai_query, query_buffer_len - query_len); - - char* response; - if (!impl.query_with_file(template_buffer, query_buffer_len, file_id, &response)) { - return 0; - } - - invoice inv; - if (!administration_reader_read_invoice_from_xml(&inv, response, strlen(response))) { - return false; - } - - invoice tmp = administration_invoice_create_empty(); - - inv.status = invoice_status::INVOICE_RECEIVED; - strops_copy(inv.id, tmp.id, MAX_LEN_ID); // TODO next_id is not being incremented - strops_copy(inv.customer.id, MY_COMPANY_ID, MAX_LEN_ID); // TODO param for incomming/exporting necessary - - strops_copy(inv.document.original_path, file_path, MAX_LEN_PATH); - strops_copy(inv.document.copy_path, "", MAX_LEN_PATH); - - a_err result = administration_invoice_import(&inv); - - free(template_buffer); - free(response); - - return 0; -} \ No newline at end of file diff --git a/src/import_service.cpp b/src/import_service.cpp new file mode 100644 index 0000000..b7a519c --- /dev/null +++ b/src/import_service.cpp @@ -0,0 +1,152 @@ +/* +* Copyright (c) 2025 Aldrik Ramaekers +* +* Permission to use, copy, modify, and/or distribute this software for any +* purpose with or without fee is hereby granted, provided that the above +* copyright notice and this permission notice appear in all copies. +* +* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#define _CRT_SECURE_NO_WARNINGS + +#include +#include +#include +#include + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include "httplib.h" +#include "log.hpp" +#include "import_service.hpp" +#include "strops.hpp" +#include "administration_reader.hpp" +#include "locales.hpp" + +ai_provider_impl _ai_get_impl() +{ + ai_provider provider = administration_get_ai_service().provider; + + switch(provider) + { + case AI_PROVIDER_OPENAI: return _chatgpt_api_provider; + default: assert(0); break; + } + + return ai_provider_impl {0}; +} + +extern const char* peppol_invoice_template; +extern const char* peppol_invoice_line_template; + +static int _ai_document_to_invoice_t(void *arg) { + import_invoice_request* request = (import_invoice_request*)arg; + char* file_path = request->file_path; + ai_provider_impl impl = _ai_get_impl(); + + request->status = import_status::IMPORT_UPLOADING_FILE; + + char file_id[100]; + if (!impl.upload_file(file_path, file_id, 100)) { + request->status = import_status::IMPORT_DONE; + request->error = I_ERR_FAILED_UPLOAD; + return 0; + } + + request->status = import_status::IMPORT_QUERYING; + + size_t query_buffer_len = 50000; + char* template_buffer = (char*)malloc(query_buffer_len); + memset(template_buffer, 0, query_buffer_len); + + strncpy(template_buffer, peppol_invoice_template, query_buffer_len); + strops_replace(template_buffer, 50000, "{{INVOICE_LINE_LIST}}", peppol_invoice_line_template); + + char* ai_query = + "\n\nI have provided a file containing an invoice. Fill in the above Peppol 3.0 template with the information from the invoice." + "Do not add any fields to the template. If you can't find data for a given field, leave it empty. Do not make up any information." + "Only return the filled out template in valid XML format. Nothing else.\n"; + + size_t query_len = strlen(template_buffer); + strncpy(template_buffer + query_len, ai_query, query_buffer_len - query_len); + + request->status = import_status::IMPORT_WAITING_FOR_RESPONSE; + + char* response; + if (!impl.query_with_file(template_buffer, query_buffer_len, file_id, &response)) { + request->status = import_status::IMPORT_DONE; + request->error = I_ERR_FAILED_QUERY; + return 0; + } + + invoice inv; + if (!administration_reader_read_invoice_from_xml(&inv, response, strlen(response))) { + request->status = import_status::IMPORT_DONE; + request->error = I_ERR_FAILED_IMPORT; + return 0; + } + + invoice tmp = administration_invoice_create_empty(); + + inv.status = invoice_status::INVOICE_RECEIVED; + strops_copy(inv.id, tmp.id, MAX_LEN_ID); // TODO next_id is not being incremented + contact my_info = administration_company_info_get(); + memcpy(&inv.customer, &my_info, sizeof(contact)); + strops_copy(inv.customer.id, MY_COMPANY_ID, MAX_LEN_ID); + + strops_copy(inv.document.original_path, file_path, MAX_LEN_PATH); + strops_copy(inv.document.copy_path, "", MAX_LEN_PATH); + + free(template_buffer); + free(response); + + request->status = import_status::IMPORT_DONE; + request->result = administration_invoice_create_copy(&inv); + return 0; +} + +import_invoice_request* ai_document_to_invoice(char* file_path) +{ + import_invoice_request* result = (import_invoice_request*)malloc(sizeof(import_invoice_request)); + result->started_at = time(NULL); + result->error = I_ERR_SUCCESS; + result->status = import_status::IMPORT_STARTING; + strops_copy(result->file_path, file_path, MAX_LEN_PATH); + + thrd_t thr; + if (thrd_create(&thr, _ai_document_to_invoice_t, result) != thrd_success) { + return 0; + } + + return result; +} + +const char* import_status_to_str(import_status status) +{ + switch(status) + { + case import_status::IMPORT_STARTING: return localize("import.status.starting"); + case import_status::IMPORT_UPLOADING_FILE: return localize("import.status.uploading_file"); + case import_status::IMPORT_QUERYING: return localize("import.status.querying"); + case import_status::IMPORT_WAITING_FOR_RESPONSE: return localize("import.status.waiting_for_result"); + case import_status::IMPORT_DONE: return localize("import.status.done"); + } + return ""; +} + +const char* import_error_to_str(i_err error) +{ + switch(error) + { + case I_ERR_FAILED_UPLOAD: return localize("import.error.upload"); + case I_ERR_FAILED_QUERY: return localize("import.error.query"); + case I_ERR_FAILED_IMPORT: return localize("import.error.import"); + } + return ""; +} \ No newline at end of file diff --git a/src/locales/en.cpp b/src/locales/en.cpp index 0152928..d311b36 100644 --- a/src/locales/en.cpp +++ b/src/locales/en.cpp @@ -196,6 +196,17 @@ locale_entry en_locales[] = { {"statement.tax", "Tax"}, {"statement.expenses", "Expenses"}, {"statement.profit", "Profit"}, + + // Import service. + {"import.status.starting","Starting import"}, + {"import.status.uploading_file","Uploading file"}, + {"import.status.querying","Querying AI provider"}, + {"import.status.waiting_for_result","Waiting for result"}, + {"import.status.done","Import completed"}, + + {"import.error.upload","Failure: Upload failed"}, + {"import.error.query","Failure: Querying service failed"}, + {"import.error.import","Failure: Failed to import result from service"}, }; const int en_locale_count = sizeof(en_locales) / sizeof(en_locales[0]); \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index bdddd98..8475008 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -129,7 +129,7 @@ int main(int argc, char** argv) io.Fonts->AddFontFromFileTTF("c:\\Windows\\Fonts\\segoeui.ttf"); //io.Fonts->AddFontFromFileTTF("c:\\Windows\\Fonts\\seguisym.ttf"); fontBold = io.Fonts->AddFontFromFileTTF("c:\\Windows\\Fonts\\segoeuib.ttf"); - fontBig = io.Fonts->AddFontFromFileTTF("c:\\Windows\\Fonts\\segoeuib.ttf", 36); + fontBig = io.Fonts->AddFontFromFileTTF("c:\\Windows\\Fonts\\segoeuib.ttf", 30); //io.Fonts->AddFontFromFileTTF("../../misc/fonts/DroidSans.ttf"); //io.Fonts->AddFontFromFileTTF("../../misc/fonts/Roboto-Medium.ttf"); //io.Fonts->AddFontFromFileTTF("../../misc/fonts/Cousine-Regular.ttf"); diff --git a/src/ui/ui_expenses.cpp b/src/ui/ui_expenses.cpp index 153e6b7..c02cdce 100644 --- a/src/ui/ui_expenses.cpp +++ b/src/ui/ui_expenses.cpp @@ -27,7 +27,9 @@ #include "administration.hpp" #include "administration_writer.hpp" #include "locales.hpp" -#include "ai_service.hpp" +#include "import_service.hpp" + +static import_invoice_request* active_import_request = 0; static view_state current_view_state = view_state::LIST; static invoice active_invoice = {0}; @@ -46,7 +48,13 @@ void ui_destroy_expenses() void ui_setup_expenses() { - current_view_state = view_state::LIST; + if (active_import_request != 0) { + current_view_state = view_state::VIEW_IMPORT_REQUEST; + } + else { + current_view_state = view_state::LIST; + } + active_invoice = administration_invoice_create_empty(); u32 invoice_items_count = MAX_BILLING_ITEMS; @@ -176,13 +184,13 @@ static void ui_draw_expenses_list() char import_file_path[MAX_LEN_PATH] = {0}; ImGui::SameLine(); if (ImGui::FormInvoiceFileSelector("+ Import", import_file_path)) { // @localize - //current_view_state = view_state::CREATE; + current_view_state = view_state::VIEW_IMPORT_REQUEST; active_invoice = administration_invoice_create_empty(); // @leak active_invoice.customer = administration_company_info_get(); active_invoice.is_outgoing = 0; active_invoice.status = invoice_status::INVOICE_RECEIVED; - ai_document_to_invoice(import_file_path); + active_import_request = ai_document_to_invoice(import_file_path); } if (current_page >= max_page-1) current_page = max_page-1; @@ -336,6 +344,50 @@ static void ui_draw_expense_view() draw_expense_form(&active_invoice, true); } +static void ui_draw_import_request() +{ + assert(active_import_request); + + if (active_import_request->status == import_status::IMPORT_DONE) { + if (active_import_request->error == I_ERR_SUCCESS) { + active_invoice = active_import_request->result; + current_view_state = view_state::CREATE; + active_import_request = 0; + return; + } + else { + if (ImGui::Button(localize("form.back"))) { + current_view_state = view_state::LIST; + active_import_request = 0; + return; + } + } + } + + ImGui::PushFont(fontBig); + + ImVec2 windowSize = ImGui::GetWindowSize(); + float radius = 60.0f; + + const char* text = import_status_to_str(active_import_request->status); + if (active_import_request->error != I_ERR_SUCCESS) text = import_error_to_str(active_import_request->error); + ImVec2 textSize = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2((windowSize.x - textSize.x) * 0.5f, + (windowSize.y) * 0.5f - radius - 40.0f)); + ImGui::Text(text); + + if (active_import_request->error == I_ERR_SUCCESS) { + ImGui::SetCursorPos(ImVec2((windowSize.x - radius*2) * 0.5f, + (windowSize.y - radius*2) * 0.5f)); + + const ImVec4 col = ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered); + const ImVec4 bg = ImGui::GetStyleColorVec4(ImGuiCol_Button); + ImGui::LoadingIndicatorCircle("##loadingAnim", radius, bg, col, 10, 4.0f); + } + + ImGui::PopFont(); +} + void ui_draw_expenses() { switch(current_view_state) @@ -344,5 +396,6 @@ void ui_draw_expenses() case view_state::CREATE: ui_draw_expense_create(); break; case view_state::EDIT: ui_draw_expense_update(); break; case view_state::VIEW: ui_draw_expense_view(); break; + case view_state::VIEW_IMPORT_REQUEST: ui_draw_import_request(); break; } } \ No newline at end of file -- cgit v1.2.3-70-g09d2