From d17d8776a6bdfc7f7345b0d3d6c824d164486836 Mon Sep 17 00:00:00 2001 From: Umang Varma Date: Mon, 6 Jan 2025 20:54:06 -0800 Subject: [PATCH 1/2] Bulk importer for Fava This is my first pass at making a bulk importer for Fava. Known issues that I hope to address: * Checkboxes don't honor shift-clicks to select a range * Doesn't use Svelte v5 runes. Nice to haves that I also hope to work on in the future: * A drag and drop interface for moving transactions (either individually, or all selected transactions). --- frontend/css/bulk-importer.css | 41 ++++ frontend/css/style.css | 8 +- frontend/src/api/validators.ts | 1 + frontend/src/main.ts | 1 + .../reports/bulkimporter/BulkExtract.svelte | 221 ++++++++++++++++++ .../bulkimporter/IndividualEntryEdit.svelte | 59 +++++ .../reports/bulkimporter/OtherEntryRow.svelte | 60 +++++ .../bulkimporter/SimpleTransactionRow.svelte | 37 +++ frontend/src/reports/bulkimporter/index.ts | 58 +++++ frontend/src/reports/import/FileList.svelte | 4 +- frontend/src/reports/import/Import.svelte | 32 ++- src/fava/core/fava_options.py | 1 + 12 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 frontend/css/bulk-importer.css create mode 100644 frontend/src/reports/bulkimporter/BulkExtract.svelte create mode 100644 frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte create mode 100644 frontend/src/reports/bulkimporter/OtherEntryRow.svelte create mode 100644 frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte create mode 100644 frontend/src/reports/bulkimporter/index.ts diff --git a/frontend/css/bulk-importer.css b/frontend/css/bulk-importer.css new file mode 100644 index 000000000..8fd22c93a --- /dev/null +++ b/frontend/css/bulk-importer.css @@ -0,0 +1,41 @@ +.bulk-importer span.select, +.bulk-importer span.edit { + width: 2rem; +} + +.bulk-importer span.flag { + width: 3rem; +} + +.bulk-importer span.description { + flex: 1; +} + +.bulk-importer span.datecell { + width: 6rem; +} + +.bulk-importer span.datecell, +.bulk-importer span.flag { + text-align: center; + background-color: var(--entry-background); +} + +.bulk-importer span.edit button { + padding: 2px 4px; + margin: 0; + font-weight: bold; + background: var(--background); +} + +.bulk-importer span.edit button:hover { + background: var(--background-darker); +} + +.bulk-importer .postings { + font-size: 0.9em; + background-color: var(--journal-postings); +} +.bulk-importer .duplicate { + text-decoration: line-through; + } \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index 5aac33bd4..048e52bb5 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -186,7 +186,7 @@ } } -.journal .balance { +.journal .balance, .bulk-importer .balance { --entry-background: hsl(120deg 100% 90%); } @@ -230,7 +230,7 @@ --entry-background: hsl(35deg 100% 80%); } -.journal { +.journal, .bulk-importer { --journal-postings: hsl(0deg 0% 92%); --journal-metadata: hsl(210deg 44% 67%); --journal-tag: hsl(210deg 61% 64%); @@ -242,7 +242,7 @@ @media (prefers-color-scheme: dark) { :root { - .journal .balance { + .journal .balance, .bulk-importer .balance { --entry-background: hsl(120deg 50% 15%); } @@ -286,7 +286,7 @@ --entry-background: hsl(35deg 100% 20%); } - .journal { + .journal, .bulk-importer { --journal-postings: hsl(0deg 0% 10%); --journal-hover-highlight: hsl(0deg 0% 20% / 60%); } diff --git a/frontend/src/api/validators.ts b/frontend/src/api/validators.ts index 77d44a8c2..70043bd3c 100644 --- a/frontend/src/api/validators.ts +++ b/frontend/src/api/validators.ts @@ -66,6 +66,7 @@ const fava_options = object({ insert_entry: array( object({ date: string, filename: string, lineno: number, re: string }), ), + use_bulk_importer: boolean, use_external_editor: boolean, }); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0344f5fff..44080fc93 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -19,6 +19,7 @@ import "../css/help.css"; import "../css/journal-table.css"; import "../css/notifications.css"; import "../css/tree-table.css"; +import "../css/bulk-importer.css"; // Polyfill for customised builtin elements in Webkit import "@ungap/custom-elements"; diff --git a/frontend/src/reports/bulkimporter/BulkExtract.svelte b/frontend/src/reports/bulkimporter/BulkExtract.svelte new file mode 100644 index 000000000..f2bf62bcd --- /dev/null +++ b/frontend/src/reports/bulkimporter/BulkExtract.svelte @@ -0,0 +1,221 @@ + + + +
+

{_("Import")}: {account}

+
+ + {#if num_selected > 0} + + + + + {/if} +
+
+ {#each transactions_by_target as [target, transactions], i} +

{target}

+
    +
  • +

    + + Date + F + Description + Amount + +

    +
  • + {#each transactions as transaction} + { + entry_to_edit = transaction.transaction; + }} + /> + {/each} +
+ {/each} + {_("Other entries")} +
    +
  • +

    + + Date + F + Description + Amount + +

    +
  • + {#each other_entries as entry} + { + entry_to_edit = entry; + }} + /> + {/each} +
+
+
+
+ +
+
+
+ { + entry_to_edit = undefined; + entries = entries; + }} + /> +
+ + diff --git a/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte new file mode 100644 index 000000000..5de8eedf9 --- /dev/null +++ b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte @@ -0,0 +1,59 @@ + + + +
{}}> +

{_("Import")}

+ {#if entry} +
+

{_("Edit Entry")}

+ + +
+
+ +
+ {/if} +
+
+ + diff --git a/frontend/src/reports/bulkimporter/OtherEntryRow.svelte b/frontend/src/reports/bulkimporter/OtherEntryRow.svelte new file mode 100644 index 000000000..8e08ec36c --- /dev/null +++ b/frontend/src/reports/bulkimporter/OtherEntryRow.svelte @@ -0,0 +1,60 @@ + + +{#if entry instanceof Transaction} +
  • +

    + + {entry.date} + * + {entry.payee || ""}{#if entry.payee && entry.narration}{/if}{entry.narration || ""} + + +

    + +
  • +{:else if entry instanceof Balance} +
  • +

    + + {entry.date} + Bal + {entry.account} + {entry.amount.number} {entry.amount.currency} + +

    +
  • +{/if} + + diff --git a/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte b/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte new file mode 100644 index 000000000..76bf20dd9 --- /dev/null +++ b/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte @@ -0,0 +1,37 @@ + + +
  • + +
  • + + diff --git a/frontend/src/reports/bulkimporter/index.ts b/frontend/src/reports/bulkimporter/index.ts new file mode 100644 index 000000000..bec271693 --- /dev/null +++ b/frontend/src/reports/bulkimporter/index.ts @@ -0,0 +1,58 @@ +import { Transaction } from "../../entries"; +import type { Entry } from "../../entries"; + +function negate_amount(amount: string) { + if (amount.trimStart().startsWith("-")) { + return amount.trimStart().slice(1); + } else { + return "-" + amount; + } +} + +// Wrapper around Transaction. +export class SimpleTransaction { + // The original transaction. This is guaranteed to have exactly 2 + // postings (checked in the constructor). + transaction: Transaction; + readonly origin_account: string; + readonly target_posting_index: number; + readonly origin_posting_index: number; + // Remember the index in the upstream list of `SimpleTransaction`s. Because + // of Svelte's reactivity, we have to maintain paralell data structures to + // track local edits that are not purely functional (as otherwise, the changes + // get overwritten any time Svelte recomputes derived values). + readonly index: number; + + constructor(transaction: Transaction, origin_account: string, index: number) { + this.transaction = transaction; + this.origin_account = origin_account; + this.index = index; + + let target_postings = this.transaction.postings + .map((posting, index) => ({ posting: posting, index: index })) + .filter(({ posting, }) => !posting.is_empty() && posting.account !== this.origin_account); + if (this.transaction.postings.length !== 2) { + throw new Error("A transaction with more than 2 postings is not a 'simple' transaction.") + } + if (target_postings.length !== 1) { + throw new Error("Expected exactly one posting whose account was not" + + this.origin_account + ", but found " + + JSON.stringify(target_postings)) + } else { + this.target_posting_index = target_postings[0]!.index; + this.origin_posting_index = 1 - this.target_posting_index; + } + } + + getTargetAccount() { + return this.transaction.postings[this.target_posting_index]!.account; + } + + setTargetAccount(target: string) { + this.transaction.postings[this.target_posting_index]!.account = target; + } + + getAmount() { + return this.transaction.postings[this.origin_posting_index]!.amount || negate_amount(this.transaction.postings[this.target_posting_index]!.amount) + } +} \ No newline at end of file diff --git a/frontend/src/reports/import/FileList.svelte b/frontend/src/reports/import/FileList.svelte index 6cd97e893..04de96803 100644 --- a/frontend/src/reports/import/FileList.svelte +++ b/frontend/src/reports/import/FileList.svelte @@ -9,7 +9,7 @@ export let selected: string | null; export let remove: (name: string) => unknown; export let move: (name: string, a: string, newName: string) => unknown; - export let extract: (name: string, importer: string) => unknown; + export let extract: (name: string, importer: string, account:string) => unknown; {#each files as file} @@ -45,7 +45,7 @@